Fase de implementación: Predicción de clientes en un ecommerce con dataset altamente desbalanceado
Índice
- Índice
- Introducción
- Librerías
- Repositorio
-
Datos y preparación
- Origen y estructura de los datos
- Calidad de los datos e integración
- Análisis exploratorio univariante
- Análisis exploratorio multivariante
- Evaluación de asociación: Pearson, T-Test y Chi²
- Preparación del dataset para el modelado
- Generación de conjuntos de entrenamiento y validación
-
Diseño y optimización del sistema predictivo
- Planteamiento general del problema de modelado
- Fase I – Screening inicial de modelos
- Fase II – Ciclos iterativos de optimización
-
Análisis de umbrales de probabilidad
- Conclusiones
-
Interpretabilidad e Insights
- Insight 1 — Importancia global
- Insight 2 — Perfiles explicativos
- Insight 3 — Robustez y estabilidad
- Conclusiones del bloque
-
Aplicaciones de negocio del modelo
- Priorización de acciones comerciales
- Gestión dinámica de campañas
- Explotación de perfiles
- Apoyo a decisiones estratégicas
-
Limitaciones y riesgos
- Sesgos de datos
- Riesgo de sobreajuste
- Dependencia de señales compuestas
- Variables sensibles y ética
- Qué no aprende el modelo
- Riesgos operativos
-
Implementación y escalabilidad
- Coste computacional
- Despliegue
- Reentrenamiento
- Monitorización
- Escalabilidad
-
Extensiones y próximos pasos
- Segmentación en el modelo
- Análisis avanzado de perfiles
- Optimización del entrenamiento
- Refactorización
- Producción
- Conclusiones finales
- Bibliografía
Introducción
La presente fase de implementación aborda el problema de predecir la probabilidad de compra de un usuario en un entorno de comercio electrónico caracterizado por un fuerte desbalance de clases, donde aproximadamente el 1% de los usuarios realiza una conversión frente al 99% restante. Este tipo de escenarios es habitual en problemas de propensión de compra y supone un reto relevante tanto desde el punto de vista técnico como operativo, especialmente en términos de discriminación efectiva de la clase minoritaria y control de falsas alarmas.
El objetivo del trabajo no se limita a la obtención de un modelo con buen rendimiento estadístico, sino que persigue explícitamente la generación de valor tangible para el negocio. En línea con la literatura reciente en analítica de marketing, el sistema desarrollado se concibe como una herramienta de apoyo a la toma de decisiones, orientada a identificar clientes potenciales, optimizar la asignación de recursos comerciales y mejorar la eficiencia de las estrategias de captación y retención, maximizando el retorno de la inversión (García y Rodríguez, 2024; Pandiyarajan et al., 2025).
La selección de modelos, técnicas de balanceo y estrategias de evaluación se apoya en una revisión actualizada del estado del arte en predicción de intención de compra y aprendizaje automático aplicado al marketing digital. Estudios recientes destacan el uso de modelos de gradient boosting, ensamblados balanceados y métricas específicas para datos desbalanceados —como PR-AUC o lift— como enfoques especialmente efectivos en este tipo de problemas (Liu et al., 2024; Singh et al., 2024). No obstante, este marco teórico se utiliza como referencia y no como una restricción metodológica, evitando condicionar el diseño del sistema a una única familia de modelos y priorizando la validación empírica en el contexto concreto del dataset analizado.
El proceso de modelado se ha desarrollado siguiendo una metodología estructurada, iterativa y progresiva, concebida como un embudo de decisión que permite refinar de forma controlada cada componente del sistema. Este enfoque combina una exploración amplia en las fases iniciales con un refinamiento progresivo orientado a robustez, interpretabilidad y eficiencia computacional en las etapas finales, tal como recomiendan trabajos recientes en analítica predictiva aplicada al e-commerce (Ortiz-Clavijo, 2024; Yasnig, 2025).
- Análisis exploratorio y preparación de datos: Se realizó un análisis exploratorio exhaustivo del conjunto de datos, incluyendo el estudio de distribuciones, correlaciones y patrones de comportamiento de los usuarios. Este análisis permitió identificar señales relevantes asociadas a interacción, recencia y respuesta a canales, coherentes con los hallazgos reportados en estudios recientes sobre comportamiento de compra online (Pandiyarajan et al., 2025; Wang et al., 2024).
- Selección inicial de modelos y estrategias de balanceo: Se evaluó una amplia combinación de algoritmos de clasificación junto con estrategias modernas para el tratamiento del desbalance, incluyendo el uso de pesos internos, sobremuestreo adaptativo y enfoques híbridos. La literatura reciente señala que no existe una técnica de balanceo universalmente óptima y que su efectividad depende de la interacción con el modelo y la estructura de los datos, lo que justifica un enfoque experimental amplio en esta fase (Kim et al., 2024; Liu et al., 2024).
- Evaluación de ensambles y meta-estimadores: A partir de los modelos base más prometedores, se exploraron técnicas de ensamble, incluyendo voting y stacking. En línea con trabajos recientes, se optó por un enfoque de stacking que combina modelos de boosting heterogéneos mediante un meta-modelo de regresión logística balanceada, con el objetivo de mejorar la robustez y la capacidad de generalización en escenarios desbalanceados.
- Optimización de hiperparámetros y análisis de variables: Con la arquitectura del modelo definida, se procedió a la optimización de hiperparámetros mediante Optuna, priorizando métricas alineadas con los objetivos de negocio y adecuadas para clases minoritarias, especialmente PR-AUC. Este proceso se complementó con análisis de importancia de variables y reingeniería de features, siguiendo enfoques recientes que enfatizan la necesidad de equilibrar rendimiento predictivo e interpretabilidad (Martínez et al., 2024).
- Generación de utilidad de negocio e interpretabilidad: El modelo final no solo produce predicciones probabilísticas, sino que permite extraer conclusiones accionables mediante análisis de umbrales, interpretabilidad global y local (SHAP) y segmentación no supervisada de usuarios. Estos elementos facilitan la identificación de perfiles de alto valor y segmentos estructuralmente poco propensos a la compra, reforzando la aplicabilidad práctica del sistema, tal como recomiendan estudios recientes en explainable AI aplicada al marketing (Martínez et al., 2024; Yasnig, 2025).
En conjunto, se ha establecido un proceso sistemático, automatizado y cíclico, que combina el respaldo del estado del arte reciente con una validación empírica intensiva y un refinamiento progresivo. Este enfoque permite obtener modelos robustos, alineados con los objetivos de negocio y capaces de generar conocimiento interpretable y accionable en un entorno real de comercio electrónico.
Repositorio de software
El código fuente completo, la documentación del proyecto y los artefactos generados durante el desarrollo del Trabajo Fin de Máster están disponibles en el repositorio GitHub del proyecto:
👉 https://github.com/LeoWorks/TFM_Client_Prediction
Este notebook representa el punto de entrada principal para la ejecución y reproducción de los resultados presentados.
Librerias
En el desarrollo del proyecto se ha empleado un conjunto de librerías especializadas que cubren todas las fases del proceso de análisis y modelización. Para la manipulación y tratamiento de datos se utilizaron NumPy y Pandas, mientras que la visualización exploratoria se apoyó en Matplotlib y Seaborn. El análisis estadístico se realizó con SciPy y Statsmodels, proporcionando herramientas para contrastes y evaluación de relaciones entre variables.
La fase de modelización se basó principalmente en Scikit-learn, que aportó algoritmos de clasificación, técnicas de preprocesado y herramientas de validación. Dado el fuerte desbalanceo de la variable objetivo, se incorporaron técnicas avanzadas de re-muestreo mediante Imbalanced-Learn. Para maximizar el rendimiento predictivo se integraron modelos de gradient boosting de alto rendimiento, concretamente XGBoost, LightGBM y CatBoost. Asimismo, se emplearon Joblib y utilidades de IPython para la optimización computacional y la integración con el entorno Jupyter.
En conjunto, este ecosistema de librerías permitió desarrollar un flujo de trabajo robusto, reproducible y eficiente para el preprocesado de datos, la construcción de modelos y la evaluación del rendimiento.
## 📦 Imports del proyecto
# =====================================================
# Standard Library
# =====================================================
import json
import os
import warnings
import pickle
from pathlib import Path
from time import time
from datetime import datetime
# =====================================================
# IPython / Jupyter
# =====================================================
from IPython.display import display, Markdown, HTML
# =====================================================
# Ciencia de Datos — NumPy, Pandas, Visualización
# =====================================================
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# =====================================================
# SciPy / Statsmodels
# =====================================================
from scipy.stats import chi2_contingency, ttest_ind
from scipy.cluster.hierarchy import linkage, dendrogram
from statsmodels.api import add_constant
from statsmodels.stats.outliers_influence import variance_inflation_factor
# =====================================================
# Scikit-Learn — Model Selection
# =====================================================
from sklearn.model_selection import (
StratifiedKFold,
cross_val_predict,
train_test_split
)
# =====================================================
# Scikit-Learn — Métricas
# =====================================================
from sklearn.metrics import (
precision_recall_curve,
auc,
roc_auc_score,
f1_score,
recall_score,
confusion_matrix,
classification_report,
roc_curve
)
# =====================================================
# Scikit-Learn — Preprocessing
# =====================================================
from sklearn.preprocessing import (
RobustScaler,
StandardScaler,
OneHotEncoder
)
from sklearn.compose import ColumnTransformer
# =====================================================
# Scikit-Learn — Modelos Lineales
# =====================================================
from sklearn.linear_model import (
LogisticRegression,
LogisticRegressionCV,
PassiveAggressiveClassifier,
RidgeClassifier,
SGDClassifier
)
# =====================================================
# Scikit-Learn — SVM
# =====================================================
from sklearn.svm import LinearSVC
# =====================================================
# Scikit-Learn — Árboles y Ensambles
# =====================================================
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import (
GradientBoostingClassifier,
RandomForestClassifier,
StackingClassifier
)
# =====================================================
# Scikit-Learn — Utilidades avanzadas
# =====================================================
from sklearn.calibration import CalibratedClassifierCV
from sklearn.pipeline import Pipeline as SklearnPipeline
from sklearn.decomposition import PCA
from sklearn.inspection import (
permutation_importance,
PartialDependenceDisplay
)
# =====================================================
# Imbalanced-Learn
# =====================================================
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.under_sampling import (
TomekLinks,
RandomUnderSampler,
NearMiss
)
from imblearn.over_sampling import (
SMOTE,
BorderlineSMOTE,
RandomOverSampler,
ADASYN
)
from imblearn.combine import (
SMOTEENN,
SMOTETomek
)
from imblearn.ensemble import (
BalancedRandomForestClassifier,
EasyEnsembleClassifier,
RUSBoostClassifier,
BalancedBaggingClassifier
)
# =====================================================
# Boosting Frameworks
# =====================================================
from xgboost import XGBClassifier
from catboost import CatBoostClassifier
from lightgbm import LGBMClassifier
import lightgbm as lgb
import catboost as cb
# =====================================================
# Interpretabilidad / Explainable AI
# =====================================================
import shap
import lime
import lime.lime_tabular
# =====================================================
# Clustering y Segmentación
# =====================================================
from sklearn.cluster import KMeans, DBSCAN
from kmodes.kprototypes import KPrototypes
import hdbscan
import gower
# =====================================================
# Utilidades generales
# =====================================================
import joblib
from tqdm import tqdm
Datos y preparación
En este apartado se describe el proceso de análisis exploratorio y preparación de los datos utilizado como base para el desarrollo del modelo predictivo. El objetivo no es únicamente comprender la estructura y calidad de la información disponible, sino transformar los datos en representaciones adecuadas para el modelado, garantizando coherencia, consistencia y validez estadística a lo largo de todo el proceso.
# ----------------------------
# Funciones para imprimir
# ----------------------------
def tit(txt):
print("")
print("="*100)
print(txt)
print("="*100)
def cierre():
print("-"*100)
print("")
def exito(txt):
print(f"✅ {txt}")
# ----------------------------
# Carga de datos
# ----------------------------
try:
# Ruta relativa a la carpeta donde están los datos
ruta = r"src"
# Cargar los archivos
fichas = pd.read_csv(os.path.join(ruta, "Fichas.txt"), sep="\t", decimal=",") # tabulador, forzar a int y decimal con coma
primera_ficha = pd.read_csv(os.path.join(ruta, "PrimeraFicha.txt"), sep="|")
sesiones = pd.read_csv(os.path.join(ruta, "Sesiones.txt"), sep="|")
usuarios = pd.read_csv(os.path.join(ruta, "Usuarios.txt"), sep="|")
print("✅ ¡Archivos cargados con éxito")
except FileNotFoundError:
print(f"❌ Error: No se encontraron los archivos en la ruta: {ruta_base}. Verifica la ruta y los nombres de archivo.")
except Exception as e:
print(f"❌ Ocurrió un error al cargar los archivos: {e}")
dataset_names =[("Usuarios", usuarios), ("Fichas", fichas), ("Primera_ficha", primera_ficha), ("Sesiones", sesiones)]
# ----------------------------
# Guardar dataset
# ----------------------------
def guardar_csv(df, folder, file):
os.makedirs(folder, exist_ok=True)
output_path = os.path.join(folder,file)
df.to_csv(output_path, index=False)
exito(f"{file} guardado en {output_path}")
✅ ¡Archivos cargados con éxito
Origen y estructura de los datos
Los datos empleados en este trabajo proceden de múltiples fuentes internas del entorno de comercio electrónico y recogen información complementaria sobre el comportamiento de los usuarios. Cada dataset captura una dimensión específica de la interacción —como la consulta de fichas o la actividad en sesiones— y todos comparten un identificador común (IDUSUARIO) que permite su integración posterior.
En esta fase se analiza de forma preliminar la estructura, volumen y calidad de cada fuente. Los conjuntos de datos presentan un número elevado de registros, ausencia de duplicados y una proporción muy reducida de valores nulos, lo que indica una buena calidad estructural inicial. El análisis descriptivo revela distribuciones asimétricas y valores extremos en variables de actividad, anticipando la necesidad de un tratamiento específico en las fases posteriores de exploración y preparación de los datos.
# ----------------------------
# Funciones para analizar datos
# ----------------------------
def info(df: pd.DataFrame, name: str) -> None:
"""
Realiza un análisis de estructura y calidad de un DataFrame
Parameters:
-----------
df : pd.DataFrame
DataFrame a analizar
name : str
Nombre descriptivo del dataset
Returns:
--------
None
Solo imprime resumen, no retorna valores
"""
# ⚙️ Configuración Temporal de Pandas
# Guardar y forzar el formato sin separadores de miles/decimales para describe()
original_float_format = pd.get_option('display.float_format')
pd.set_option('display.float_format', '{:.2f}'.format) # Muestra 2 decimales sin separador de miles
tit(f"📊 Análisis de estructura y calidad: {name}")
# 1. Dimensiones (Corregido: Se elimina ':,' para no usar separadores)
print(f"Dimensiones: {df.shape[0]} filas, {df.shape[1]} columnas.")
if 'IDUSUARIO' in df.columns:
print(f"Valores únicos en IDUSUARIO: {df['IDUSUARIO'].nunique()}")
print(f"Duplicados: {df.duplicated().sum()}")
cierre()
# 2. Tipos de Datos y No-Nulos
print("Tipos de datos y valores no-nulos:")
tipos_nulos = pd.DataFrame({
'Dtype': df.dtypes,
'No-Nulos': df.count(),
'Nulos': df.isnull().sum(),
'% Nulos': (df.isnull().sum() / len(df) * 100).round(2)
})
def resaltar_nulos(s):
return ['font-weight: bold' if v > 0 else '' for v in s]
# Usamos style.format con formato simple para No-Nulos/Nulos
display(tipos_nulos.style
.apply(resaltar_nulos, subset=['Nulos', '% Nulos'])
.format({'No-Nulos': '{:.0f}', 'Nulos': '{:.0f}'}) # Formato sin comas
)
cierre()
# 3. Estadísticas Descriptivas (Utiliza la configuración global aplicada arriba)
print("Estadísticas Descriptivas (Numéricas y Categóricas):")
try:
desc_df = df.describe(include='all').transpose()
# Los números en esta tabla usarán la configuración '{:.2f}' (sin separadores)
display(desc_df)
except Exception as e:
print(f"Advertencia: No se pudieron mostrar las estadísticas descriptivas. Error: {e}")
cierre()
# 4. Primeras filas
print("Primeras 3 filas (df.head(3)):")
display(df.head(3))
cierre()
# ⚠️ Restaurar configuración de float_format original al finalizar
pd.set_option('display.float_format', original_float_format)
def quick_info(df: pd.DataFrame, name: str = "") -> None:
"""
Realiza un análisis rápido y ultra compacto de un DataFrame,
enfocándose en los aspectos más críticos para revisión inicial.
Parameters:
-----------
df : pd.DataFrame
DataFrame a analizar
name : str
Nombre descriptivo del dataset
Returns:
--------
None
Solo imprime resumen, no retorna valores
"""
tit(f"ANÁLISIS RÁPIDO: {name}")
print(f"📊 Shape: {df.shape[0]} × {df.shape[1]}")
print(f"📝 Memoria: {df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
# Resumen de nulos
nulos = df.isnull().sum()
if nulos.sum() > 0:
print(f"⚠️ Columnas con nulos: {sum(nulos > 0)}")
for col, n in nulos[nulos > 0].items():
print(f" - {col}: {n} ({n/len(df)*100:.1f}%)")
else:
print("✅ Sin valores nulos")
# Tipos de datos únicos
tipos = df.dtypes.value_counts()
print(f"🎯 Tipos de datos: {dict(tipos)}")
cierre()
info(usuarios,"Usuarios")
==================================================================================================== 📊 Análisis de estructura y calidad: Usuarios ==================================================================================================== Dimensiones: 195165 filas, 7 columnas. Valores únicos en IDUSUARIO: 195165 Duplicados: 0 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| IDUSUARIO | int64 | 195165 | 0 | 0.000000 |
| FEC_REGISTRO | object | 195165 | 0 | 0.000000 |
| CANAL | object | 195165 | 0 | 0.000000 |
| IND_CLIENTE | int64 | 195165 | 0 | 0.000000 |
| FEC_CLIENTE | object | 1984 | 193181 | 98.980000 |
| BONDAD_EMAIL | int64 | 195165 | 0 | 0.000000 |
| TIPOUSUARIO | object | 195165 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| IDUSUARIO | 195165.00 | NaN | NaN | NaN | 9218638.36 | 168204.33 | 8926449.00 | 9073585.00 | 9218608.00 | 9363449.00 | 9511079.00 |
| FEC_REGISTRO | 195165 | 365 | 8/9/2021 0:00:00 | 957 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| CANAL | 195165 | 3 | Directorios | 157004 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| IND_CLIENTE | 195165.00 | NaN | NaN | NaN | 0.01 | 0.10 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| FEC_CLIENTE | 1984 | 521 | 29/6/2021 0:00:00 | 14 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| BONDAD_EMAIL | 195165.00 | NaN | NaN | NaN | 15.89 | 6.03 | -10.00 | 9.00 | 20.00 | 20.00 | 20.00 |
| TIPOUSUARIO | 195165 | 2 | PF | 164203 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| IDUSUARIO | FEC_REGISTRO | CANAL | IND_CLIENTE | FEC_CLIENTE | BONDAD_EMAIL | TIPOUSUARIO | |
|---|---|---|---|---|---|---|---|
| 0 | 8928344 | 4/1/2021 0:00:00 | Directorios | 0 | NaN | 9 | PF |
| 1 | 8928349 | 4/1/2021 0:00:00 | Directorios | 0 | NaN | 20 | PF |
| 2 | 8928354 | 4/1/2021 0:00:00 | Directorios | 0 | NaN | 20 | PF |
----------------------------------------------------------------------------------------------------
info(fichas,"Fichas")
==================================================================================================== 📊 Análisis de estructura y calidad: Fichas ==================================================================================================== Dimensiones: 153127 filas, 4 columnas. Valores únicos en IDUSUARIO: 48901 Duplicados: 0 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| IDCONSUMO | float64 | 153127 | 0 | 0.000000 |
| IDUSUARIO | float64 | 153127 | 0 | 0.000000 |
| FECHACONSUMO | object | 153127 | 0 | 0.000000 |
| EMPCONSUL_ID | int64 | 153127 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| IDCONSUMO | 153127.00 | NaN | NaN | NaN | 65313131.15 | 3406488.89 | 61005151.00 | 63038656.00 | 65195235.00 | 66882466.50 | 95225327.00 |
| IDUSUARIO | 153127.00 | NaN | NaN | NaN | 9224083.00 | 168918.13 | 8926449.00 | 9082220.50 | 9222203.00 | 9368451.00 | 9511025.00 |
| FECHACONSUMO | 153127 | 1158 | 14/12/2021 0:00:00 | 1311 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| EMPCONSUL_ID | 153127.00 | NaN | NaN | NaN | 1700330302209783.50 | 17782965778793.04 | 1700009200200000.00 | 1700099123000000.00 | 1700256119600000.00 | 1700433825400000.00 | 7240437294100000.00 |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| IDCONSUMO | IDUSUARIO | FECHACONSUMO | EMPCONSUL_ID | |
|---|---|---|---|---|
| 0 | 76894891.00 | 8970313.00 | 2/1/2023 0:00:00 | 1700503514600000 |
| 1 | 76903551.00 | 9085119.00 | 3/1/2023 0:00:00 | 1700506783100000 |
| 2 | 76895534.00 | 9281731.00 | 2/1/2023 0:00:00 | 1700147141000000 |
----------------------------------------------------------------------------------------------------
info(primera_ficha,"Primera Ficha")
==================================================================================================== 📊 Análisis de estructura y calidad: Primera Ficha ==================================================================================================== Dimensiones: 167203 filas, 3 columnas. Valores únicos en IDUSUARIO: 167203 Duplicados: 0 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| IDUSUARIO | int64 | 167203 | 0 | 0.000000 |
| EMPCONSUL_ID | object | 167198 | 5 | 0.000000 |
| USUARIOSQUECONSULTAN | float64 | 167198 | 5 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| IDUSUARIO | 167203.00 | NaN | NaN | NaN | 9215355.47 | 167944.09 | 8926449.00 | 9069291.50 | 9214822.00 | 9359645.50 | 9511079.00 |
| EMPCONSUL_ID | 167198 | 91213 | 1700525227500000 | 324 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| USUARIOSQUECONSULTAN | 167198.00 | NaN | NaN | NaN | 9.26 | 26.04 | 1.00 | 1.00 | 2.00 | 6.00 | 324.00 |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| IDUSUARIO | EMPCONSUL_ID | USUARIOSQUECONSULTAN | |
|---|---|---|---|
| 0 | 9510378 | 1700206046100000 | 4.00 |
| 1 | 9027646 | 1700206046100000 | 4.00 |
| 2 | 9329248 | 1700206046100000 | 4.00 |
----------------------------------------------------------------------------------------------------
info(sesiones,"Sesiones")
==================================================================================================== 📊 Análisis de estructura y calidad: Sesiones ==================================================================================================== Dimensiones: 211476 filas, 4 columnas. Valores únicos en IDUSUARIO: 195002 Duplicados: 0 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| IDUSUARIO | int64 | 211476 | 0 | 0.000000 |
| FECHA | object | 211476 | 0 | 0.000000 |
| CLICKS | int64 | 211476 | 0 | 0.000000 |
| SESIONES | int64 | 211476 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| IDUSUARIO | 211476.00 | NaN | NaN | NaN | 9218517.77 | 168477.29 | 8926449.00 | 9072845.25 | 9218478.50 | 9363429.75 | 9511079.00 |
| FECHA | 211476 | 1164 | 8/9/2021 0:00:00 | 1007 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| CLICKS | 211476.00 | NaN | NaN | NaN | 4.22 | 5.36 | 1.00 | 3.00 | 3.00 | 4.00 | 631.00 |
| SESIONES | 211476.00 | NaN | NaN | NaN | 2.28 | 2.10 | 1.00 | 2.00 | 2.00 | 2.00 | 310.00 |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| IDUSUARIO | FECHA | CLICKS | SESIONES | |
|---|---|---|---|---|
| 0 | 8960000 | 28/1/2021 0:00:00 | 1 | 1 |
| 1 | 8970000 | 4/2/2021 0:00:00 | 3 | 2 |
| 2 | 9010000 | 25/2/2021 0:00:00 | 1 | 1 |
----------------------------------------------------------------------------------------------------
📌 Conclusiones
A partir del análisis preliminar de los datos, se identificaron las siguientes incidencias relevantes:- Presencia de valores faltantes en la variable primera_ficha.
- Inconsistencias derivadas de la conversión automática de tipos en las variables de fecha.
- Errores de tipificación en los campos IDUSUARIO e IDCONSUMO del dataset de fichas, así como en EMPCONSUL_ID y USUARIOSQUECONSULTAN del dataset primera_ficha.
Calidad de los datos e integracion
Correccion de errores de tipificacion y valores faltantes
primera_ficha: rellenar valores faltantes y correccion errores de tipificación
tit("Deteccion de valores no numericos")
no_numericos = primera_ficha[
pd.to_numeric(primera_ficha["EMPCONSUL_ID"], errors="coerce").isna()
]
print(no_numericos)
cierre()
tit("Eliminamos valores faltantes y nos quedamos solo con la columna de USUARIOSQUECONSULTAN")
# Seleccionar solo las columnas deseadas
primera_ficha_limpio = primera_ficha[['IDUSUARIO', 'USUARIOSQUECONSULTAN']]
# Eliminar filas con valores nulos en cualquier columna
primera_ficha_limpio = primera_ficha_limpio.dropna()
primera_ficha_limpio["USUARIOSQUECONSULTAN"] = pd.to_numeric(
primera_ficha_limpio["USUARIOSQUECONSULTAN"], errors="coerce"
).astype("Int64")
exito("Dataset primera_ficha_limpio generado")
quick_info(primera_ficha_limpio,"primera_ficha_limpio")
====================================================================================================
Deteccion de valores no numericos
====================================================================================================
IDUSUARIO EMPCONSUL_ID USUARIOSQUECONSULTAN
27562 9000384 sector-farmaceutico 1.0
77965 9181379 sector-transporte-terrestre-carga 2.0
77966 9398467 sector-transporte-terrestre-carga 2.0
99480 9219184 sector-cosmetico 2.0
99481 9411107 sector-cosmetico 2.0
103338 9404216 sector-contact-center-bpo 1.0
117992 9126594 sector-cemento 2.0
117993 9349870 sector-cemento 2.0
145351 9411710 sector-telecomunicaciones 1.0
145815 9439236 sector-electrodomesticos 1.0
167075 9501300 sector-hardware-software 1.0
167198 8972692 NaN NaN
167199 9059131 NaN NaN
167200 9195745 NaN NaN
167201 9240131 NaN NaN
167202 9398799 NaN NaN
----------------------------------------------------------------------------------------------------
====================================================================================================
Eliminamos valores faltantes y nos quedamos solo con la columna de USUARIOSQUECONSULTAN
====================================================================================================
✅ Dataset primera_ficha_limpio generado
====================================================================================================
ANÁLISIS RÁPIDO: primera_ficha_limpio
====================================================================================================
📊 Shape: 167198 × 2
📝 Memoria: 4.0 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(1), Int64Dtype(): np.int64(1)}
----------------------------------------------------------------------------------------------------
ficha: correccion errores de tipificación
tit("Conversión a int64 en IDCONSUMO e IDUSUARIO de Fichas")
columnas_a_cambiar = ['IDCONSUMO', 'IDUSUARIO']
fichas_limpio = fichas.copy()
for col in columnas_a_cambiar:
# 2. Convertir a tipo numérico (Int64)
fichas_limpio[col] = pd.to_numeric(fichas[col], errors='coerce').astype('Int64')
exito(f"Columna {col} convertida a int64")
quick_info(fichas_limpio,"fichas_limpio")
====================================================================================================
Conversión a int64 en IDCONSUMO e IDUSUARIO de Fichas
====================================================================================================
✅ Columna IDCONSUMO convertida a int64
✅ Columna IDUSUARIO convertida a int64
====================================================================================================
ANÁLISIS RÁPIDO: fichas_limpio
====================================================================================================
📊 Shape: 153127 × 4
📝 Memoria: 14.6 MB
✅ Sin valores nulos
🎯 Tipos de datos: {Int64Dtype(): np.int64(2), dtype('O'): np.int64(1), dtype('int64'): np.int64(1)}
----------------------------------------------------------------------------------------------------
Conversión y validación de variables temporales
Se procedió a convertir las columnas de fechas —FEC_REGISTRO y FEC_CLIENTE en usuarios, FECHACONSUMO en fichas y FECHA en sesiones— al tipo datetime de Python, garantizando un manejo consistente de la información temporal.
Posteriormente, se verificó la correcta conversión identificando valores nulos que podrían indicar errores o datos faltantes, asegurando la calidad del conjunto de datos para análisis posteriores.
tit("Convertir fechas FEC_REGISTRO, FEC_CLIENTE de usuarios, FECHACONSUMO de fichas y FECHA de sesiones")
usuarios_limpio = usuarios.copy()
usuarios_limpio["FEC_REGISTRO"] = pd.to_datetime(
usuarios_limpio["FEC_REGISTRO"],
format="%d/%m/%Y %H:%M:%S",
errors="coerce"
)
usuarios_limpio["FEC_CLIENTE"] = pd.to_datetime(
usuarios_limpio["FEC_CLIENTE"],
format="%d/%m/%Y %H:%M:%S",
errors="coerce"
)
exito("Fechas de usuarios corregidas")
fichas_limpio["FECHACONSUMO"] = pd.to_datetime(
fichas["FECHACONSUMO"],
format="%d/%m/%Y %H:%M:%S",
errors="coerce"
)
exito("Fechas de fichas corregidas")
sesiones_limpio = sesiones.copy()
sesiones_limpio["FECHA"] = pd.to_datetime(
sesiones_limpio["FECHA"],
format="%d/%m/%Y %H:%M:%S",
errors="coerce"
)
exito("Fechas de sesiones corregidas")
tit("Verificamos si hay fechas no convertidas")
print("Usuarios - fechas nulas en FEC_REGISTRO:", usuarios_limpio["FEC_REGISTRO"].isna().sum())
print("Usuarios - fechas nulas en FEC_CLIENTE (deben ser 193181):", usuarios_limpio["FEC_CLIENTE"].isna().sum())
print("Fichas - fechas nulas:", fichas_limpio["FECHACONSUMO"].isna().sum())
print("Sesiones - fechas nulas:", sesiones_limpio["FECHA"].isna().sum())
==================================================================================================== Convertir fechas FEC_REGISTRO, FEC_CLIENTE de usuarios, FECHACONSUMO de fichas y FECHA de sesiones ==================================================================================================== ✅ Fechas de usuarios corregidas ✅ Fechas de fichas corregidas ✅ Fechas de sesiones corregidas ==================================================================================================== Verificamos si hay fechas no convertidas ==================================================================================================== Usuarios - fechas nulas en FEC_REGISTRO: 0 Usuarios - fechas nulas en FEC_CLIENTE (deben ser 193181): 193181 Fichas - fechas nulas: 0 Sesiones - fechas nulas: 0
Analisis exploratorio de fechas
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Configuración simple
plt.style.use('default')
sns.set_palette("husl")
def analisis_fechas_simple(dataset_limpio_names):
"""
Análisis SUPER SIMPLE de fechas en los datasets
"""
print("="*50)
print("📅 ANÁLISIS SIMPLE DE FECHAS")
print("="*50)
for nombre, df in dataset_limpio_names:
print(f"\n📁 Dataset: {nombre}")
print(f" Filas: {len(df)}")
print(f" Columnas: {list(df.columns)}")
# 1. Buscar columnas que sean fechas
fechas_encontradas = []
for col in df.columns:
# Si ya es datetime de pandas
if pd.api.types.is_datetime64_any_dtype(df[col]):
fechas_encontradas.append(col)
if not fechas_encontradas:
print(" ❌ No hay columnas de fecha")
continue
print(f" ✅ Fechas encontradas: {fechas_encontradas}")
# 2. Análisis básico para cada columna de fecha
for fecha_col in fechas_encontradas:
print(f"\n 📊 Columna: {fecha_col}")
# Estadísticas básicas
print(f" Mínima: {df[fecha_col].min()}")
print(f" Máxima: {df[fecha_col].max()}")
print(f" Nulos: {df[fecha_col].isnull().sum()} ({df[fecha_col].isnull().mean()*100:.1f}%)")
# Extraer año y mes para análisis
df[f'{fecha_col}_year'] = df[fecha_col].dt.year
df[f'{fecha_col}_month'] = df[fecha_col].dt.month
# 3. GRÁFICOS SIMPLES
# Gráfico 1: Distribución por año
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
df[f'{fecha_col}_year'].value_counts().sort_index().plot(kind='bar', color='skyblue')
plt.title(f'Años - {nombre}')
plt.xlabel('Año')
plt.ylabel('Cantidad')
plt.xticks(rotation=45)
# Gráfico 2: Distribución por mes
plt.subplot(1, 2, 2)
meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
# Contar por mes
conteo_meses = df[f'{fecha_col}_month'].value_counts().sort_index()
# Crear lista de todos los meses (1-12)
todos_meses = list(range(1, 13))
conteo_completo = [conteo_meses.get(m, 0) for m in todos_meses]
plt.bar(meses, conteo_completo, color='lightcoral')
plt.title(f'Meses - {nombre}')
plt.xlabel('Mes')
plt.ylabel('Cantidad')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
# Gráfico 3: Serie temporal simple (si hay suficientes datos)
if len(df) > 100:
plt.figure(figsize=(12, 4))
# Agrupar por fecha (diario)
serie_diaria = df.set_index(fecha_col).resample('D').size()
# Plot simple
plt.plot(serie_diaria.index, serie_diaria.values,
color='green', linewidth=1, alpha=0.7)
# Media móvil de 7 días (más suave)
if len(serie_diaria) > 7:
media_movil = serie_diaria.rolling(window=7).mean()
plt.plot(media_movil.index, media_movil.values,
color='red', linewidth=2, label='Media 7 días')
plt.legend()
plt.title(f'Serie Temporal - {nombre}')
plt.xlabel('Fecha')
plt.ylabel('Eventos por día')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# 4. Información adicional si hay target
if 'es_cliente' in df.columns and fecha_col in df.columns:
print(f"\n 🎯 Relación con 'es_cliente':")
# Tasa de conversión por año
conversion_anual = df.groupby(f'{fecha_col}_year')['es_cliente'].mean()
print(f" Conversión por año:")
for año, tasa in conversion_anual.items():
print(f" {año}: {tasa:.1%}")
analisis_fechas_simple([("Usuarios",usuarios_limpio),("Sesiones", sesiones_limpio),("Fichas",fichas_limpio)])
==================================================
📅 ANÁLISIS SIMPLE DE FECHAS
==================================================
📁 Dataset: Usuarios
Filas: 195165
Columnas: ['IDUSUARIO', 'FEC_REGISTRO', 'CANAL', 'IND_CLIENTE', 'FEC_CLIENTE', 'BONDAD_EMAIL', 'TIPOUSUARIO']
✅ Fechas encontradas: ['FEC_REGISTRO', 'FEC_CLIENTE']
📊 Columna: FEC_REGISTRO
Mínima: 2021-01-01 00:00:00
Máxima: 2021-12-31 00:00:00
Nulos: 0 (0.0%)
📊 Columna: FEC_CLIENTE
Mínima: 2021-01-02 00:00:00
Máxima: 2024-07-31 00:00:00
Nulos: 193181 (99.0%)
📁 Dataset: Sesiones
Filas: 211476
Columnas: ['IDUSUARIO', 'FECHA', 'CLICKS', 'SESIONES']
✅ Fechas encontradas: ['FECHA']
📊 Columna: FECHA
Mínima: 2021-01-01 00:00:00
Máxima: 2024-07-31 00:00:00
Nulos: 0 (0.0%)
📁 Dataset: Fichas
Filas: 153127
Columnas: ['IDCONSUMO', 'IDUSUARIO', 'FECHACONSUMO', 'EMPCONSUL_ID']
✅ Fechas encontradas: ['FECHACONSUMO']
📊 Columna: FECHACONSUMO
Mínima: 2021-01-01 00:00:00
Máxima: 2024-07-31 00:00:00
Nulos: 0 (0.0%)
📌 Conclusiones
A partir del análisis temporal de los distintos datasets, se pueden extraer las siguientes conclusiones:
- Todos los conjuntos de datos registran información desde el 01/01/2021.
- Los datasets de fichas y sesiones finalizan en 31/07/2024.
- El volumen total disponible equivale a aproximadamente 3,5 años de datos de comportamiento.
Observaciones críticas
- La variable FEC_CLIENTE presenta un 99% de valores nulos, permaneciendo únicamente alrededor de 2.000 registros válidos.
- Todos los usuarios del dataset aparecen registrados por primera vez en el año 2021.
- El dataset primera_ficha carece de información temporal asociada a los registros.
Validación de integridad referencial entre datasets
✅ Se constata que no hay ids de usuario en los datasets secundarios que no estén presentes en el primario.
tit("Comprobacion de Ids de usuario respecto al dataset principal")
# Extraer los IDs de usuario únicos del dataset principal
usuarios_maestros = usuarios_limpio['IDUSUARIO'].copy()
# Convertir a un conjunto (Set) para búsquedas más rápidas en el filtrado posterior
set_usuarios_maestros = set(usuarios_maestros)
dataset_limpio_names =[("Usuarios", usuarios_limpio), ("Fichas", fichas_limpio), ("Primera_ficha", primera_ficha_limpio), ("Sesiones", sesiones_limpio)]
for nombre, df in dataset_limpio_names:
registros_iniciales = df.shape[0]
df_filtrado = df[df['IDUSUARIO'].isin(set_usuarios_maestros)].copy()
registros_finales = df_filtrado.shape[0]
registros_repetidos = registros_iniciales - registros_finales
print(f"\nDataFrame: {nombre}")
print(f"Número de ids: {registros_iniciales}")
print(f"Ids no presentes en dataset principal: {registros_repetidos}")
==================================================================================================== Comprobacion de Ids de usuario respecto al dataset principal ==================================================================================================== DataFrame: Usuarios Número de ids: 195165 Ids no presentes en dataset principal: 0 DataFrame: Fichas Número de ids: 153127 Ids no presentes en dataset principal: 0 DataFrame: Primera_ficha Número de ids: 167198 Ids no presentes en dataset principal: 0 DataFrame: Sesiones Número de ids: 211476 Ids no presentes en dataset principal: 0
Creacion de dataset único
Agrupacion por usuario y seleccion de columnas relevantes
Agregación de fichas: se agrupan los registros por IDUSUARIO y se calcula el número total de fichas consultadas por cada usuario. El resultado es un dataset resumido que contiene una fila por usuario y una métrica principal: total_fichas_consultadas.
Agregación de primera_ficha: dado que este dataset ya está estructurado a nivel de usuario, únicamente se seleccionan las columnas relevantes y se eliminan duplicados para asegurar una relación uno a uno. El resultado es un conjunto limpio y preparado para ser fusionado.
Agregación de sesiones: se agrupan las sesiones por IDUSUARIO y se calculan métricas de actividad, incluyendo el total de sesiones, el total de clicks y el número de días distintos en los que el usuario se conectó. Esto permite obtener indicadores de comportamiento esenciales para el modelado.
# ----------------------------------------------------
# 1. Agregación de FICHAS
# ----------------------------------------------------
tit("1. Agregación: Conteo de Fichas (fichas_agregado)")
# Agrupamos por IDUSUARIO y contamos el número de registros (fichas consultadas).
fichas_agregado = fichas_limpio.groupby("IDUSUARIO").agg(
# Contamos cualquier columna (ej. 'IDUSUARIO') para obtener el total de fichas consultadas
total_fichas_consultadas=('IDUSUARIO', 'count')
).reset_index()
exito("Dataset fichas_agregado creado con el conteo de fichas por usuario.")
quick_info(fichas_agregado, "fichas_agregado")
# ----------------------------------------------------
# 2. Agregación de PRIMERA FICHA
# ----------------------------------------------------
tit("2. Agregación: Selección de Columnas (primera_ficha_agregado)")
# Este dataset ya está a nivel de usuario (una fila por ID), solo seleccionamos las columnas.
# Usamos drop_duplicates() para asegurar 1:1, está implícito en la estructura inicial.
primera_ficha_agregado = primera_ficha_limpio[['IDUSUARIO', 'USUARIOSQUECONSULTAN']].copy()
primera_ficha_agregado = primera_ficha_agregado.drop_duplicates(subset='IDUSUARIO', keep='first')
exito("Dataset primera_ficha_agregado creado y listo para el merge.")
quick_info(primera_ficha_agregado, "primera_ficha_agregado")
# ----------------------------------------------------
# 3. Agregación de SESIONES
# ----------------------------------------------------
tit("3. Agregación: Clicks, Sesiones y Conexión de 2 Días (sesiones_agregado)")
sesiones_agregado = sesiones_limpio.groupby("IDUSUARIO").agg(
total_sesiones=('SESIONES', 'sum'),
total_clicks=('CLICKS', 'sum'),
# Conexión 2 Días Distintos (Booleano)
sesiones_en_dias_distintos=('IDUSUARIO', 'count')
).reset_index()
# Renombrar la columna booleana para claridad
sesiones_agregado.rename(
columns={'sesiones_en_dias_distintos': 'NUM_DIAS_SESIONES'},
inplace=True
)
exito("Dataset sesiones_agregado creado con métricas de clicks, sesiones e indicador booleano.")
quick_info(sesiones_agregado, "sesiones_agregado")
====================================================================================================
1. Agregación: Conteo de Fichas (fichas_agregado)
====================================================================================================
✅ Dataset fichas_agregado creado con el conteo de fichas por usuario.
====================================================================================================
ANÁLISIS RÁPIDO: fichas_agregado
====================================================================================================
📊 Shape: 48901 × 2
📝 Memoria: 0.8 MB
✅ Sin valores nulos
🎯 Tipos de datos: {Int64Dtype(): np.int64(2)}
----------------------------------------------------------------------------------------------------
====================================================================================================
2. Agregación: Selección de Columnas (primera_ficha_agregado)
====================================================================================================
✅ Dataset primera_ficha_agregado creado y listo para el merge.
====================================================================================================
ANÁLISIS RÁPIDO: primera_ficha_agregado
====================================================================================================
📊 Shape: 167198 × 2
📝 Memoria: 4.0 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(1), Int64Dtype(): np.int64(1)}
----------------------------------------------------------------------------------------------------
====================================================================================================
3. Agregación: Clicks, Sesiones y Conexión de 2 Días (sesiones_agregado)
====================================================================================================
✅ Dataset sesiones_agregado creado con métricas de clicks, sesiones e indicador booleano.
====================================================================================================
ANÁLISIS RÁPIDO: sesiones_agregado
====================================================================================================
📊 Shape: 195002 × 4
📝 Memoria: 6.0 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(4)}
----------------------------------------------------------------------------------------------------
Seleccion de columnas relevantes
Selección y derivación de features: se seleccionan columnas relevantes de usuarios_limpio y se generan variables temporales a partir de FEC_REGISTRO (mes, día de la semana, día del mes y fin de semana), eliminando finalmente la columna original de fecha.
tit("Selección de Features en usuarios_limpio")
usuarios_modelado = usuarios_limpio[[
'IDUSUARIO',
'CANAL',
'IND_CLIENTE',
'BONDAD_EMAIL',
'TIPOUSUARIO',
'FEC_REGISTRO' # Necesaria para derivar las fechas
]].copy()
# Variables derivadas de FEC_REGISTRO
usuarios_modelado["MES_REGISTRO"] = usuarios_modelado["FEC_REGISTRO"].dt.month.astype('Int64')
usuarios_modelado["DIA_SEMANA_REGISTRO"] = usuarios_modelado["FEC_REGISTRO"].dt.dayofweek.astype('Int64') # 0=Lunes, 6=Domingo
usuarios_modelado["DIA_MES_REGISTRO"] = usuarios_modelado["FEC_REGISTRO"].dt.day.astype('Int64')
usuarios_modelado["ES_FINDE_REGISTRO"] = usuarios_modelado["DIA_SEMANA_REGISTRO"].isin([5, 6]).astype(int)
exito("Variables temporales MES, DIA_MES, DIA_SEMANA y ES_FINDE añadidas.")
# Eliminar la columna de fecha de registro original
usuarios_modelado.drop(columns=["FEC_REGISTRO"], inplace=True)
exito("Columna FEC_REGISTRO eliminada.")
quick_info(usuarios_modelado, "usuarios_modelado")
====================================================================================================
Selección de Features en usuarios_limpio
====================================================================================================
✅ Variables temporales MES, DIA_MES, DIA_SEMANA y ES_FINDE añadidas.
✅ Columna FEC_REGISTRO eliminada.
====================================================================================================
ANÁLISIS RÁPIDO: usuarios_modelado
====================================================================================================
📊 Shape: 195165 × 9
📝 Memoria: 34.3 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(4), Int64Dtype(): np.int64(3), dtype('O'): np.int64(2)}
----------------------------------------------------------------------------------------------------
Añadir features derivadas a fichas (frecuencia, recencia y antigüedad)
Re-agregación de fichas: se calculan métricas de frecuencia, recencia y antigüedad para cada usuario a partir de FECHACONSUMO, utilizando como referencia la última fecha registrada en el dataset.
# Re-agregación de Fichas para incluir features de tiempo
tit("2. Re-Agregación de Fichas: Frecuencia, Recencia y Antigüedad")
# Determinar la fecha de corte (última fecha de consumo en el dataset)
FECHA_CORTE_FICHAS = fichas_limpio["FECHACONSUMO"].max()
exito(f"Fecha de Corte (Máx. FECHACONSUMO): {FECHA_CORTE_FICHAS.date()}")
# Agrupación con métricas de Frecuencia, Recencia y Antigüedad
fichas_agregado = fichas_limpio.groupby("IDUSUARIO").agg(
# Frecuencia (total de fichas consultadas)
total_fichas_consultadas=('IDUSUARIO', 'count'),
# Recencia (Días desde la última consulta de ficha)
recencia_fichas=('FECHACONSUMO', lambda x: (FECHA_CORTE_FICHAS - x.max()).days),
# Antigüedad (Días entre la primera y la última consulta)
antiguedad_comportamiento_fichas=('FECHACONSUMO', lambda x: (x.max() - x.min()).days)
).reset_index()
# Importante: total_fichas_consultadas, recencia_fichas y antiguedad_comportamiento_fichas
# serán NaN para los usuarios sin actividad después del merge. Esto se corregirá después.
exito("Features de Fichas (Frecuencia, Recencia, Antigüedad) creados.")
quick_info(fichas_agregado, "fichas_agregado")
====================================================================================================
2. Re-Agregación de Fichas: Frecuencia, Recencia y Antigüedad
====================================================================================================
✅ Fecha de Corte (Máx. FECHACONSUMO): 2024-07-31
✅ Features de Fichas (Frecuencia, Recencia, Antigüedad) creados.
====================================================================================================
ANÁLISIS RÁPIDO: fichas_agregado
====================================================================================================
📊 Shape: 48901 × 4
📝 Memoria: 1.6 MB
✅ Sin valores nulos
🎯 Tipos de datos: {Int64Dtype(): np.int64(2), dtype('int64'): np.int64(2)}
----------------------------------------------------------------------------------------------------
Añadir features derivadas a sesiones (clicks por sesion y sesiones por dia)
Derivación de features en sesiones: se crean métricas de densidad de clicks por sesión y sesiones por día de conexión, y se seleccionan las columnas finales relevantes para el análisis.
tit("Derivación de Features en sesiones_agregado")
sesiones_modelado = sesiones_agregado.copy()
# Crear métrica de densidad (Clicks por Sesión)
sesiones_modelado["CLICKS_POR_SESION"] = (
sesiones_modelado["total_clicks"] / sesiones_modelado["total_sesiones"]
).replace([np.inf, -np.inf], 0).fillna(0) # Reemplazar NaN/Inf con 0 (si total_sesiones es 0)
# Crear métrica de "Sesiones por día de conexión"
sesiones_modelado["SESIONES_POR_DIA_CONEXION"] = (
sesiones_modelado["total_sesiones"] / sesiones_modelado["NUM_DIAS_SESIONES"]
).replace([np.inf, -np.inf], 0).fillna(0)
# Seleccionar las columnas finales
sesiones_modelado = sesiones_modelado[[
'IDUSUARIO',
'total_sesiones',
'total_clicks',
'NUM_DIAS_SESIONES',
'CLICKS_POR_SESION',
'SESIONES_POR_DIA_CONEXION'
]].copy()
exito("Features de Sesiones (densidad) derivados y seleccionados.")
quick_info(sesiones_modelado, "sesiones_modelado")
====================================================================================================
Derivación de Features en sesiones_agregado
====================================================================================================
✅ Features de Sesiones (densidad) derivados y seleccionados.
====================================================================================================
ANÁLISIS RÁPIDO: sesiones_modelado
====================================================================================================
📊 Shape: 195002 × 6
📝 Memoria: 8.9 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(4), dtype('float64'): np.int64(2)}
----------------------------------------------------------------------------------------------------
tit("Procesamiento de primera_ficha_agregado")
#No hay que hacer nada... solo se cambia nombre por coherencia
primera_ficha_modelado = primera_ficha_agregado.copy()
exito("primera_ficha_agregado listo para el merge sin cambios adicionales.")
quick_info(primera_ficha_modelado, "primera_ficha_modelado")
====================================================================================================
Procesamiento de primera_ficha_agregado
====================================================================================================
✅ primera_ficha_agregado listo para el merge sin cambios adicionales.
====================================================================================================
ANÁLISIS RÁPIDO: primera_ficha_modelado
====================================================================================================
📊 Shape: 167198 × 2
📝 Memoria: 4.0 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(1), Int64Dtype(): np.int64(1)}
----------------------------------------------------------------------------------------------------
Fusion de datasets
Unificación de DataFrames: se realiza la fusión de los datasets de usuarios, fichas, sesiones y primera ficha en un único df_final mediante left joins, conservando todos los usuarios del dataset principal.
# ----------------------------------------------------
# Fusión (Merge) de los DataFrames
# ----------------------------------------------------
tit("Merge: Unificación de DataFrames en df_final")
# 1. Empezamos con el dataset maestro de usuarios
df_final = usuarios_modelado.copy()
exito(f"Base de usuarios: {df_final.shape[0]} registros.")
# 2. Merge con FICHAS agregadas (LEFT JOIN)
df_final = df_final.merge(fichas_agregado, on='IDUSUARIO', how='left')
exito("Merge con fichas_agregado completado.")
# 3. Merge con SESIONES agregadas (LEFT JOIN)
df_final = df_final.merge(sesiones_modelado, on='IDUSUARIO', how='left')
exito("Merge con sesiones_modelado completado.")
# 4. Merge con PRIMERA_FICHA agregada (LEFT JOIN)
df_final = df_final.merge(primera_ficha_modelado, on='IDUSUARIO', how='left')
exito("Merge con primera_ficha_modelado completado.")
quick_info(df_final, "df_final (Post-Merge, Pre-Imputación)")
====================================================================================================
Merge: Unificación de DataFrames en df_final
====================================================================================================
✅ Base de usuarios: 195165 registros.
✅ Merge con fichas_agregado completado.
✅ Merge con sesiones_modelado completado.
✅ Merge con primera_ficha_modelado completado.
====================================================================================================
ANÁLISIS RÁPIDO: df_final (Post-Merge, Pre-Imputación)
====================================================================================================
📊 Shape: 195165 × 18
📝 Memoria: 48.1 MB
⚠️ Columnas con nulos: 9
- total_fichas_consultadas: 146264 (74.9%)
- recencia_fichas: 146264 (74.9%)
- antiguedad_comportamiento_fichas: 146264 (74.9%)
- total_sesiones: 163 (0.1%)
- total_clicks: 163 (0.1%)
- NUM_DIAS_SESIONES: 163 (0.1%)
- CLICKS_POR_SESION: 163 (0.1%)
- SESIONES_POR_DIA_CONEXION: 163 (0.1%)
- USUARIOSQUECONSULTAN: 27967 (14.3%)
🎯 Tipos de datos: {dtype('float64'): np.int64(7), Int64Dtype(): np.int64(5), dtype('int64'): np.int64(4), dtype('O'): np.int64(2)}
----------------------------------------------------------------------------------------------------
Imputacion de nulos
- Frecuencia y métricas de sesiones: imputar con
0. - Recencia: imputar con valor extremo (máxima recencia + 999).
- Antigüedad: imputar con
-1como centinela.
Imputación estratégica de valores nulos: se realiza un tratamiento diferenciado de los nulos en el dataset final (df_final) según el tipo de métrica y la ausencia de actividad del usuario:
- Frecuencia y métricas de sesiones: los valores ausentes se imputan con
0, indicando ausencia total de actividad. - Recencia de fichas: los usuarios sin actividad reciben un valor extremo calculado como la recencia máxima + 999 días, para reflejar su inactividad.
- Antigüedad del comportamiento de fichas: se utiliza el valor centinela
-1para los casos sin registros, diferenciando claramente la ausencia de datos. En este caso el 0 es quien solo tiene 1 ficha
# ----------------------------------------------------
# Imputación Estratégica de Valores Nulos
# ----------------------------------------------------
tit("Imputación Estratégica (Tratamiento de Nulos por Ausencia de Actividad)")
# Obtenemos la Recencia Máxima para la imputación de usuarios sin actividad de fichas
DIAS_MAX_RECENCIA = (FECHA_CORTE_FICHAS - fichas_limpio["FECHACONSUMO"].min()).days + 999
exito(f"Valor de Recencia máxima para imputación (Ausencia): {DIAS_MAX_RECENCIA} días.")
# Imputación de Features de FICHAS
# Frecuencia: La ausencia de actividad es 0.
df_final['total_fichas_consultadas'] = df_final['total_fichas_consultadas'].fillna(0)
# Recencia: La ausencia de actividad se imputa al valor máximo de Recencia + 999 días.
df_final['recencia_fichas'] = df_final['recencia_fichas'].fillna(DIAS_MAX_RECENCIA)
# Antigüedad Comportamiento: La ausencia de actividad es el valor centinela -1.
df_final['antiguedad_comportamiento_fichas'] = df_final['antiguedad_comportamiento_fichas'].fillna(-1)
exito("Nulos de FICHAS tratados con éxito")
# Imputación de Features de SESIONES y PRIMERA_FICHA
# Todos los Nulos restantes (sesiones, clicks, usuarios_que_consultan, etc.)
# provienen de la ausencia total de la actividad correspondiente. Se imputan con 0.
columnas_a_cero = [
'total_sesiones',
'total_clicks',
'NUM_DIAS_SESIONES',
'CLICKS_POR_SESION',
'SESIONES_POR_DIA_CONEXION',
'USUARIOSQUECONSULTAN'
]
df_final[columnas_a_cero] = df_final[columnas_a_cero].fillna(0)
exito("Nulos de SESIONES y PRIMERA_FICHA imputados con 0 (cero actividad).")
==================================================================================================== Imputación Estratégica (Tratamiento de Nulos por Ausencia de Actividad) ==================================================================================================== ✅ Valor de Recencia máxima para imputación (Ausencia): 2306 días. ✅ Nulos de FICHAS tratados con éxito ✅ Nulos de SESIONES y PRIMERA_FICHA imputados con 0 (cero actividad).
Indicador adicional
# ----------------------------------------------------
# Creacion de columna de fichas
# ----------------------------------------------------
df_final['tiene_fichas'] = (df_final['antiguedad_comportamiento_fichas'] != -1).astype(int)
exito("Creada columna tiene_fichas")
✅ Creada columna tiene_fichas
Estandardizacion de nombres y tipos de datos
Estandarización y ajuste de tipos de datos: se renombraron las columnas de df_final a minúsculas y snake_case, aplicando cambios semánticos donde fue necesario, y se ajustaron los tipos de datos: enteros para métricas contables e indicadoras, y flotantes para variables de densidad, asegurando coherencia y consistencia del dataset para análisis posteriores.
tit("Estandarización de Nombres y Ajuste de Tipos de Datos")
# --- Paso 1: Renombrar y estandarizar variables ---
# Se utiliza un mapeo para convertir los nombres de columnas a minúsculas y snake_case (barra baja).
mapeo_columnas = {
# Cambios Semánticos y de Formato
'IND_CLIENTE': 'es_cliente',
'total_fichas_consultadas': 'total_fichas_consultadas',
'recencia_fichas': 'recencia_fichas',
'antiguedad_comportamiento_fichas': 'antiguedad_comportamiento_fichas',
'total_sesiones': 'total_sesiones',
'total_clicks': 'total_clicks',
'NUM_DIAS_SESIONES': 'num_dias_sesiones',
'CLICKS_POR_SESION': 'clicks_por_sesion',
'SESIONES_POR_DIA_CONEXION': 'sesiones_por_dia', # Cambio semántico
'USUARIOSQUECONSULTAN': 'usuarios_que_consultan_misma_primera_ficha', # Cambio semántico
# Estandarización a snake_case para las variables originales
'CANAL': 'canal',
'BONDAD_EMAIL': 'bondad_email',
'TIPOUSUARIO': 'tipo_usuario',
'MES_REGISTRO': 'mes_registro',
'DIA_SEMANA_REGISTRO': 'dia_semana_registro',
'DIA_MES_REGISTRO': 'dia_mes_registro',
'ES_FINDE_REGISTRO': 'es_finde_registro',
}
# Aplicar el renombramiento. Primero, asegurarse de que las claves del mapeo
# existen en las columnas actuales para evitar errores si los nombres base ya cambiaron.
columnas_actuales = df_final.columns.tolist()
mapeo_filtrado = {k: v for k, v in mapeo_columnas.items() if k in columnas_actuales}
df_final.rename(columns=mapeo_filtrado, inplace=True)
exito("Nombres de columnas estandarizados a minúsculas y snake_case.")
# Corregir los tipos de las variables numéricas (que pueden ser float debido a NaN/imputación)
# Variables que deben ser INTEGER (enteros)
columnas_enteras = [
'recencia_fichas',
'antiguedad_comportamiento_fichas',
'total_sesiones',
'total_clicks',
'num_dias_sesiones',
'total_fichas_consultadas'
]
# Las variables booleanas/indicadoras (0/1) también se pueden tratar como enteros
columnas_indicadoras = ['es_cliente', 'es_finde_registro']
# Columnas de registro (ya son Int64 en el código anterior, pero se refuerza)
columnas_registro = ['mes_registro', 'dia_semana_registro', 'dia_mes_registro']
# Unir todas las columnas que deben ser enteras
todas_enteras = columnas_enteras + columnas_indicadoras + columnas_registro
for col in todas_enteras:
if col in df_final.columns:
# Los valores -1 (centinela) requieren que se use 'Int64' (integer con soporte para NaN)
# o convertir a int y asegurar que no hay NaN (lo cual ya hicimos).
# Como hemos usado -1 para imputar y 0 para el resto, podemos usar el tipo 'int'.
# Se usa .astype(int) para asegurar que se elimina el punto flotante.
df_final[col] = df_final[col].astype(int)
# Las variables de densidad deben ser FLOAT (ya que son resultados de divisiones)
columnas_float = ['clicks_por_sesion', 'sesiones_por_dia']
for col in columnas_float:
if col in df_final.columns:
df_final[col] = df_final[col].astype(float)
exito("Tipos de datos ajustados a enteros y flotantes según la naturaleza de la variable.")
# --- Paso 4: Revisión final del dataset con nuevos nombres y tipos ---
quick_info(df_final, "DF_FINAL (ESTANDARIZADO Y TIPADO)")
# Para finalizar, se imprime el resultado con los nuevos nombres
print("\n📝 Nombres de columnas finales:")
print(df_final.columns.tolist())
# Se puede agregar un .head() para verificar los valores, si la función quick_info no lo hace
# print("\nPrimeras 5 filas:")
# print(df_final.head())
====================================================================================================
Estandarización de Nombres y Ajuste de Tipos de Datos
====================================================================================================
✅ Nombres de columnas estandarizados a minúsculas y snake_case.
✅ Tipos de datos ajustados a enteros y flotantes según la naturaleza de la variable.
====================================================================================================
ANÁLISIS RÁPIDO: DF_FINAL (ESTANDARIZADO Y TIPADO)
====================================================================================================
📊 Shape: 195165 × 19
📝 Memoria: 48.8 MB
✅ Sin valores nulos
🎯 Tipos de datos: {dtype('int64'): np.int64(14), dtype('O'): np.int64(2), dtype('float64'): np.int64(2), Int64Dtype(): np.int64(1)}
----------------------------------------------------------------------------------------------------
📝 Nombres de columnas finales:
['IDUSUARIO', 'canal', 'es_cliente', 'bondad_email', 'tipo_usuario', 'mes_registro', 'dia_semana_registro', 'dia_mes_registro', 'es_finde_registro', 'total_fichas_consultadas', 'recencia_fichas', 'antiguedad_comportamiento_fichas', 'total_sesiones', 'total_clicks', 'num_dias_sesiones', 'clicks_por_sesion', 'sesiones_por_dia', 'usuarios_que_consultan_misma_primera_ficha', 'tiene_fichas']
Eliminacion de IdUsuario
Limpieza final del dataset: se elimina la columna IDUSUARIO del conjunto de datos destinado al modelado, preservando una copia separada con los identificadores de usuario (df_final_Usuario) para su posible uso posterior a nivel de negocio y acciones dirigidas sobre usuarios individuales.
# ----------------------------------------------------
# Eliminacion IDUSUARIO
# ----------------------------------------------------
tit("7. Limpieza Final: Eliminación de IDUSUARIO")
df_final_Usuario=df_final.copy()
# Eliminación de IDUSUARIO
df_final.drop(columns=['IDUSUARIO'], inplace=True)
exito("IDUSUARIO eliminado del dataset final de modelado.")
# Revisión final del dataset
info(df_final, "DF_FINAL")
==================================================================================================== 7. Limpieza Final: Eliminación de IDUSUARIO ==================================================================================================== ✅ IDUSUARIO eliminado del dataset final de modelado. ==================================================================================================== 📊 Análisis de estructura y calidad: DF_FINAL ==================================================================================================== Dimensiones: 195165 filas, 18 columnas. Duplicados: 107342 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| canal | object | 195165 | 0 | 0.000000 |
| es_cliente | int64 | 195165 | 0 | 0.000000 |
| bondad_email | int64 | 195165 | 0 | 0.000000 |
| tipo_usuario | object | 195165 | 0 | 0.000000 |
| mes_registro | int64 | 195165 | 0 | 0.000000 |
| dia_semana_registro | int64 | 195165 | 0 | 0.000000 |
| dia_mes_registro | int64 | 195165 | 0 | 0.000000 |
| es_finde_registro | int64 | 195165 | 0 | 0.000000 |
| total_fichas_consultadas | int64 | 195165 | 0 | 0.000000 |
| recencia_fichas | int64 | 195165 | 0 | 0.000000 |
| antiguedad_comportamiento_fichas | int64 | 195165 | 0 | 0.000000 |
| total_sesiones | int64 | 195165 | 0 | 0.000000 |
| total_clicks | int64 | 195165 | 0 | 0.000000 |
| num_dias_sesiones | int64 | 195165 | 0 | 0.000000 |
| clicks_por_sesion | float64 | 195165 | 0 | 0.000000 |
| sesiones_por_dia | float64 | 195165 | 0 | 0.000000 |
| usuarios_que_consultan_misma_primera_ficha | Int64 | 195165 | 0 | 0.000000 |
| tiene_fichas | int64 | 195165 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| canal | 195165 | 3 | Directorios | 157004 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| es_cliente | 195165.00 | NaN | NaN | NaN | 0.01 | 0.10 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| bondad_email | 195165.00 | NaN | NaN | NaN | 15.89 | 6.03 | -10.00 | 9.00 | 20.00 | 20.00 | 20.00 |
| tipo_usuario | 195165 | 2 | PF | 164203 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| mes_registro | 195165.00 | NaN | NaN | NaN | 6.54 | 3.30 | 1.00 | 4.00 | 7.00 | 9.00 | 12.00 |
| dia_semana_registro | 195165.00 | NaN | NaN | NaN | 2.49 | 1.78 | 0.00 | 1.00 | 2.00 | 4.00 | 6.00 |
| dia_mes_registro | 195165.00 | NaN | NaN | NaN | 15.76 | 8.65 | 1.00 | 8.00 | 16.00 | 23.00 | 31.00 |
| es_finde_registro | 195165.00 | NaN | NaN | NaN | 0.15 | 0.36 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| total_fichas_consultadas | 195165.00 | NaN | NaN | NaN | 0.78 | 3.21 | 0.00 | 0.00 | 0.00 | 1.00 | 433.00 |
| recencia_fichas | 195165.00 | NaN | NaN | NaN | 2002.49 | 530.65 | 0.00 | 1304.00 | 2306.00 | 2306.00 | 2306.00 |
| antiguedad_comportamiento_fichas | 195165.00 | NaN | NaN | NaN | 1.99 | 38.53 | -1.00 | -1.00 | -1.00 | 0.00 | 1246.00 |
| total_sesiones | 195165.00 | NaN | NaN | NaN | 2.47 | 2.87 | 0.00 | 2.00 | 2.00 | 2.00 | 398.00 |
| total_clicks | 195165.00 | NaN | NaN | NaN | 4.57 | 6.65 | 0.00 | 3.00 | 3.00 | 4.00 | 772.00 |
| num_dias_sesiones | 195165.00 | NaN | NaN | NaN | 1.08 | 0.53 | 0.00 | 1.00 | 1.00 | 1.00 | 106.00 |
| clicks_por_sesion | 195165.00 | NaN | NaN | NaN | 1.67 | 0.66 | 0.00 | 1.50 | 1.50 | 1.67 | 37.00 |
| sesiones_por_dia | 195165.00 | NaN | NaN | NaN | 2.26 | 1.95 | 0.00 | 2.00 | 2.00 | 2.00 | 310.00 |
| usuarios_que_consultan_misma_primera_ficha | 195165.00 | <NA> | <NA> | <NA> | 7.93 | 24.32 | 0.00 | 1.00 | 2.00 | 5.00 | 324.00 |
| tiene_fichas | 195165.00 | NaN | NaN | NaN | 0.25 | 0.43 | 0.00 | 0.00 | 0.00 | 1.00 | 1.00 |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| canal | es_cliente | bondad_email | tipo_usuario | mes_registro | dia_semana_registro | dia_mes_registro | es_finde_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | total_clicks | num_dias_sesiones | clicks_por_sesion | sesiones_por_dia | usuarios_que_consultan_misma_primera_ficha | tiene_fichas | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Directorios | 0 | 9 | PF | 1 | 0 | 4 | 0 | 0 | 2306 | -1 | 1 | 1 | 1 | 1.00 | 1.00 | 0 | 0 |
| 1 | Directorios | 0 | 20 | PF | 1 | 0 | 4 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 |
| 2 | Directorios | 0 | 20 | PF | 1 | 0 | 4 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 |
----------------------------------------------------------------------------------------------------
Analisis exploratorio univariante
Análisis univariante: se examinan las variables categóricas mediante frecuencias relativas y gráficos de barras, y las variables numéricas mediante histogramas y boxplots para identificar distribuciones y posibles valores atípicos.
# Target
target_col = 'es_cliente'
# Numéricas
numerical_cols = [
'total_fichas_consultadas', 'recencia_fichas', 'antiguedad_comportamiento_fichas',
'total_sesiones', 'total_clicks', 'num_dias_sesiones', 'clicks_por_sesion',
'sesiones_por_dia', 'usuarios_que_consultan_misma_primera_ficha'
]
# Categóricas
categorical_cols = [
'canal', 'bondad_email', 'tipo_usuario', 'mes_registro', 'dia_semana_registro',
'dia_mes_registro', 'es_finde_registro', 'tiene_fichas'
]
Categoricas
df=df_final
print("\nFrecuencia relativa:")
for col in categorical_cols:
print(f"\n{df[col].value_counts(normalize=True).head().round(3)}")
print("\n Graficas de distribución\n")
# 2.6. Gráficos de barras
num_cols = len(categorical_cols)
fig, axes = plt.subplots(
nrows=((num_cols - 1)//3) + 1,
ncols=min(num_cols, 3),
figsize=(15, 10)
)
axes = axes.flatten()
for idx, col in enumerate(categorical_cols):
df[col].value_counts().plot(kind='bar', ax=axes[idx])
axes[idx].set_title(f'{col}')
# Eliminar los ejes sobrantes
for i in range(len(categorical_cols), len(axes)):
fig.delaxes(axes[i])
plt.tight_layout(pad=3.0, w_pad=2.0, h_pad=2.0)
plt.show()
Frecuencia relativa: canal Directorios 0.804 SEO 0.127 SEM 0.068 Name: proportion, dtype: float64 bondad_email 20 0.656 9 0.314 0 0.022 -10 0.007 1 0.000 Name: proportion, dtype: float64 tipo_usuario PF 0.841 PJ 0.159 Name: proportion, dtype: float64 mes_registro 8 0.099 9 0.099 10 0.094 3 0.092 11 0.090 Name: proportion, dtype: float64 dia_semana_registro 2 0.182 1 0.181 3 0.174 0 0.161 4 0.151 Name: proportion, dtype: float64 dia_mes_registro 9 0.037 8 0.036 23 0.036 12 0.036 15 0.036 Name: proportion, dtype: float64 es_finde_registro 0 0.848 1 0.152 Name: proportion, dtype: float64 tiene_fichas 0 0.749 1 0.251 Name: proportion, dtype: float64 Graficas de distribución
📌Conclusiones del Análisis Descriptivo Univariante
- canal: Dependencia masiva en Directorios (80.4%), siendo SEO (12.7%) y SEM (6.8%) minoritarios.
- bondad_email: Distribución fuertemente sesgada a valores altos (~97% en 20 y 9), indicando alta fiabilidad de los correos.
- tipo_usuario: Predominio de Persona Física (PF) con 84.1% sobre Persona Jurídica (PJ).
- mes_registro: Distribución equilibrada a lo largo del año (meses top cerca del 9-10%), sin picos estacionales.
- dia_semana_registro / es_finde_registro: Mayor actividad en días laborables (84.8%). La distribución por día del mes es uniforme.
- tiene_fichas: El 74.9% de los usuarios registrados no tiene fichas asociadas.
Numéricas
df = df_final
# 2.2. Distribuciones
df[numerical_cols].hist(bins=30, figsize=(15, 10))
plt.suptitle('Distribución Variables Numéricas')
plt.show()
# ✅ 2.3. Boxplots mejorados (uno por variable)
num_cols = len(numerical_cols)
fig, axes = plt.subplots(
nrows=((num_cols - 1)//3) + 1, # filas dinámicas
ncols=min(num_cols, 3), # máximo 3 columnas
figsize=(15, 5 * ((num_cols - 1)//3 + 1)) # ajustar altura
)
axes = axes.flatten()
for idx, col in enumerate(numerical_cols):
axes[idx].boxplot(df[col].dropna(), vert=True)
axes[idx].set_title(f'Outliers - {col}')
# Eliminar ejes sobrantes
for i in range(len(numerical_cols), len(axes)):
fig.delaxes(axes[i])
plt.tight_layout()
plt.show()
📌Conclusiones: Forma de las Distribuciones
- Las variables de actividad (sesiones, clics, fichas) presentan alta asimetría positiva y curtosis extrema, con la presencia de miles de valores atípicos (outliers).
- La forma de estas distribuciones está fuertemente sesgada hacia valores bajos. Esto implica que la mayoría de los usuarios muestra actividad mínima, siendo muy pocos los que registran valores extremadamente altos.
- Las variables de naturaleza temporal (mes, día de la semana/mes) son bastante simétricas y no muestran la presencia de valores atípicos significativos.
- La variable
bondad_emailexhibe una ligera asimetría negativa (sesgada a la izquierda), identificándose solo una pequeña cantidad de valores atípicos. - Las variables binarias o categóricas con dos niveles (como
es_finde_registro) presentan un fuerte desbalance de clases.
Analisis expaloratorio multivariante
El análisis multivariante se centra en la relación con la variable objetivo es_cliente. Se utilizan dos métodos: para variables cualitativas, se calcula la tasa de conversión a cliente por categoría; para variables cuantitativas, se emplea el boxplot condicional para visualizar la disparidad en la distribución entre clientes y no clientes. El objetivo es identificar las características con el mayor potencial predictivo.
Categoricas
tit("Análisis de la Relaciónes vs. Target (es_cliente)")
# ----------------------------------------------------
# Cálculo de la Tasa de Clientes por Categoría
# ----------------------------------------------------
def analizar_relacion(
df: pd.DataFrame,
columna_grupo: str,
objetivo: str = 'es_cliente',
ordenar_por_tasa: bool = False,
en_porcentaje: bool = False,
decimales: int = 4
) -> pd.DataFrame:
"""
Agrupa por `columna_grupo` y calcula:
- tasa_clientes: media de `objetivo` (proporción de 1s)
- conteo_usuarios: cantidad de filas por grupo
Parámetros:
df: DataFrame de entrada.
columna_grupo: columna por la que se agrupa.
objetivo: columna binaria 0/1 del target (por defecto 'es_cliente').
ordenar_por_tasa: si True, ordena por la tasa de clientes (desc).
en_porcentaje: si True, convierte la tasa a porcentaje con sufijo '%'.
decimales: número de decimales para redondeo de la tasa.
Retorna:
DataFrame con columnas: [columna_grupo, tasa_clientes, conteo_usuarios]
"""
res = (
df.groupby(columna_grupo)[objetivo]
.agg(tasa_clientes='mean', conteo_usuarios='count')
.reset_index()
)
if ordenar_por_tasa:
res = res.sort_values(by='tasa_clientes', ascending=False, kind='mergesort')
if en_porcentaje:
res['tasa_clientes'] = (res['tasa_clientes'] * 100).round(decimales).astype(str) + '%'
else:
res['tasa_clientes'] = res['tasa_clientes'].round(decimales)
return res
# ------------------------------
# Ejecución: replicar tu resultado
# ------------------------------
for columna in categorical_cols:
analisis = analizar_relacion(
df_final,
columna_grupo=columna,
objetivo='es_cliente',
ordenar_por_tasa=False,
en_porcentaje=True,
decimales=4)
print(analisis.to_markdown(index=False, numalign="left"))
print("")
==================================================================================================== Análisis de la Relaciónes vs. Target (es_cliente) ==================================================================================================== | canal | tasa_clientes | conteo_usuarios | |:------------|:----------------|:------------------| | Directorios | 0.7261% | 157004 | | SEM | 1.7664% | 13304 | | SEO | 2.45% | 24857 | | bondad_email | tasa_clientes | conteo_usuarios | |:---------------|:----------------|:------------------| | -10 | 0.0% | 1408 | | 0 | 0.4465% | 4255 | | 1 | 0.0% | 40 | | 9 | 0.106% | 61344 | | 20 | 1.483% | 128118 | | tipo_usuario | tasa_clientes | conteo_usuarios | |:---------------|:----------------|:------------------| | PF | 0.7412% | 164203 | | PJ | 2.4772% | 30962 | | mes_registro | tasa_clientes | conteo_usuarios | |:---------------|:----------------|:------------------| | 1 | 0.9877% | 12656 | | 2 | 1.1082% | 16423 | | 3 | 0.9585% | 17944 | | 4 | 1.1243% | 16277 | | 5 | 1.1392% | 14660 | | 6 | 1.1593% | 15526 | | 7 | 0.9588% | 17000 | | 8 | 1.01% | 19405 | | 9 | 1.032% | 19379 | | 10 | 0.9329% | 18437 | | 11 | 0.8918% | 17605 | | 12 | 0.883% | 9853 | | dia_semana_registro | tasa_clientes | conteo_usuarios | |:----------------------|:----------------|:------------------| | 0 | 1.0803% | 31380 | | 1 | 1.0185% | 35347 | | 2 | 1.0882% | 35471 | | 3 | 1.0118% | 33899 | | 4 | 1.0343% | 29393 | | 5 | 0.7944% | 16869 | | 6 | 0.9214% | 12806 | | dia_mes_registro | tasa_clientes | conteo_usuarios | |:-------------------|:----------------|:------------------| | 1 | 1.1043% | 5705 | | 2 | 0.9295% | 6132 | | 3 | 1.0402% | 5864 | | 4 | 1.1413% | 6046 | | 5 | 1.0219% | 6263 | | 6 | 1.0309% | 6402 | | 7 | 1.0275% | 5742 | | 8 | 1.0289% | 7095 | | 9 | 0.8413% | 7251 | | 10 | 1.1853% | 6834 | | 11 | 0.8771% | 6613 | | 12 | 0.9773% | 6958 | | 13 | 0.9596% | 6878 | | 14 | 0.9776% | 6035 | | 15 | 1.0063% | 6956 | | 16 | 0.9753% | 6357 | | 17 | 1.3816% | 6080 | | 18 | 0.9489% | 6218 | | 19 | 1.0242% | 6737 | | 20 | 0.9327% | 6111 | | 21 | 0.9828% | 6207 | | 22 | 0.8877% | 6872 | | 23 | 1.1711% | 7002 | | 24 | 0.861% | 6272 | | 25 | 1.1209% | 6602 | | 26 | 1.1201% | 6785 | | 27 | 0.9317% | 6440 | | 28 | 0.8552% | 6314 | | 29 | 1.1063% | 6237 | | 30 | 1.0515% | 5516 | | 31 | 1.0981% | 2641 | | es_finde_registro | tasa_clientes | conteo_usuarios | |:--------------------|:----------------|:------------------| | 0 | 1.0466% | 165490 | | 1 | 0.8492% | 29675 | | tiene_fichas | tasa_clientes | conteo_usuarios | |:---------------|:----------------|:------------------| | 0 | 0.3644% | 146264 | | 1 | 2.9672% | 48901 |
📌 Conclusiones: Relación Variables Categóricas vs. Target
- canal: Los canales SEO (2.45%) y SEM (1.77%) presentan una tasa de conversión significativamente superior a la del canal mayoritario, Directorios (0.73%).
- bondad_email: Existe una correlación positiva con la calidad. El nivel más alto (20) convierte al 1.48%, mientras que los niveles inferiores (9, 0, -10, 1) tienen tasas de conversión cercanas o iguales a 0%.
- tipo_usuario: El tipo de usuario Persona Jurídica (PJ) convierte a una tasa de 2.48%, siendo más de tres veces superior a la de Persona Física (PF, 0.74%).
- mes_registro: Las tasas de conversión se mantienen relativamente estables, oscilando ligeramente alrededor del 1.0% a lo largo de los meses.
- dia_semana_registro y es_finde_registro: La conversión es ligeramente superior en días laborables (alrededor del 1.05%) y disminuye los fines de semana (0.85%).
- dia_mes_registro: La tasa muestra fluctuaciones dispersas, sin un patrón claro o tendencia estacional evidente dentro del mes.
- tiene_fichas: Es el factor con la mayor disparidad predictiva. Los usuarios con fichas (1) convierten al 2.97%, una tasa 8 veces mayor que aquellos sin fichas (0.36%).
📊 Variables con Mayor Potencial Predictivo
- tiene_fichas: Diferencia extrema en la tasa de conversión (2.97% vs 0.36%) que lo establece como el indicador más relevante.
- tipo_usuario: La diferencia de conversión entre PJ y PF (2.48% vs 0.74%) es alta y muy útil para la segmentación inicial.
- canal: La disparidad entre SEO/SEM y Directorios permite optimizar las estrategias de adquisición.
- bondad_email: Muestra una clara relación monotónica con la conversión, aportando valor en la priorización de registros.
Numéricas
import math
import matplotlib.pyplot as plt
# Número de variables
num_vars = len(numerical_cols) # en tu caso, 9
cols = 3 # número de columnas por fila
rows = math.ceil(num_vars / cols) # calcula filas necesarias
fig, axes = plt.subplots(rows, cols, figsize=(15, 5 * rows))
axes = axes.flatten()
for idx, col in enumerate(numerical_cols):
df_final.boxplot(column=col, by='es_cliente', ax=axes[idx])
axes[idx].set_title(f'{col} by es_cliente')
axes[idx].set_xlabel("")
axes[idx].set_ylabel("")
# Eliminar ejes sobrantes si hay
for i in range(num_vars, len(axes)):
fig.delaxes(axes[i])
plt.suptitle('')
plt.tight_layout()
plt.show()
tit("Análisis de Percentiles para Discretización (Binning)")
# -------------------------------------------------------------------
# Definición de las variables candidatas a discretización
# -------------------------------------------------------------------
variables_a_analizar = [
'total_fichas_consultadas',
'total_sesiones',
'total_clicks',
'clicks_por_sesion',
'sesiones_por_dia',
'num_dias_sesiones',
'antiguedad_comportamiento_fichas',
'sesiones_por_dia'
]
# Definición de los percentiles de interés (desde la mediana hasta el extremo)
percentiles = [
0.05,
0.10,
0.20,
0.25,
0.5,
0.75,
0.9,
0.95,
0.99,
0.995,
0.999,
0.9999
]
# -------------------------------------------------------------------
# Cálculo de Percentiles
# -------------------------------------------------------------------
# Crear un diccionario para almacenar los resultados del análisis
resultados_percentiles = {}
for col in variables_a_analizar:
if col in df_final.columns:
# Calcular los valores de los percentiles para la columna actual
valores_percentiles = df_final[col].quantile(percentiles)
# Almacenar los resultados
resultados_percentiles[col] = valores_percentiles
else:
print(f"Advertencia: La columna '{col}' no se encontró en df_final.")
# -------------------------------------------------------------------
# Presentación de Resultados
# -------------------------------------------------------------------
# Convertir el diccionario a un DataFrame para una visualización más clara
df_percentiles = pd.DataFrame(resultados_percentiles)
df_percentiles.index = pd.Index(percentiles).map(lambda p: f'{p:.2%}')
df_percentiles = df_percentiles.T # Transponer para que las variables sean filas
# Añadir el valor Mínimo y Máximo a la tabla
min_max_data = {}
for col in variables_a_analizar:
if col in df_final.columns:
min_max_data[col] = {
'Min': df_final[col].min(),
'Max': df_final[col].max()
}
df_min_max = pd.DataFrame(min_max_data).T[['Min', 'Max']]
df_percentiles = pd.concat([df_min_max, df_percentiles], axis=1)
tit("Tabla de Percentiles (Distribución de Comportamiento)")
print(df_percentiles)
exito("Análisis de percentiles completado. La tabla muestra la concentración de datos en los cuartiles superiores.")
====================================================================================================
Análisis de Percentiles para Discretización (Binning)
====================================================================================================
====================================================================================================
Tabla de Percentiles (Distribución de Comportamiento)
====================================================================================================
Min Max 5.00% 10.00% 20.00% 25.00% \
total_fichas_consultadas 0.0 433.0 0.0 0.0 0.0 0.0
total_sesiones 0.0 398.0 1.0 1.0 2.0 2.0
total_clicks 0.0 772.0 1.0 1.0 3.0 3.0
clicks_por_sesion 0.0 37.0 1.0 1.0 1.5 1.5
sesiones_por_dia 0.0 310.0 1.0 1.0 2.0 2.0
num_dias_sesiones 0.0 106.0 1.0 1.0 1.0 1.0
antiguedad_comportamiento_fichas -1.0 1246.0 -1.0 -1.0 -1.0 -1.0
50.00% 75.00% 90.00% 95.00% 99.00% \
total_fichas_consultadas 0.0 1.000000 2.0 5.0 8.0
total_sesiones 2.0 2.000000 4.0 6.0 10.0
total_clicks 3.0 4.000000 8.0 16.0 25.0
clicks_por_sesion 1.5 1.666667 2.5 3.0 4.0
sesiones_por_dia 2.0 2.000000 3.0 5.0 8.0
num_dias_sesiones 1.0 1.000000 1.0 2.0 3.0
antiguedad_comportamiento_fichas -1.0 0.000000 0.0 0.0 17.0
99.50% 99.90% 99.99%
total_fichas_consultadas 11.000000 26.000 112.0000
total_sesiones 12.000000 25.000 103.0000
total_clicks 29.000000 50.000 222.9672
clicks_por_sesion 4.333333 6.000 15.0000
sesiones_por_dia 10.000000 20.000 67.4836
num_dias_sesiones 3.000000 5.000 13.0000
antiguedad_comportamiento_fichas 170.000000 708.672 1118.4836
✅ Análisis de percentiles completado. La tabla muestra la concentración de datos en los cuartiles superiores.
📌 Conclusiones
- Variables altamente informativas para clientes:
total_fichas_consultadas,recencia_fichas,antiguedad_comportamiento_fichas,total_sesiones,total_clicks,num_dias_sesiones,sesiones_por_dia,clicks_por_sesion.
Los usuarios que compran tienden a tener valores mayores en estas métricas, mostrando actividad y engagement más alto. - Observaciones sobre outliers: Algunas variables presentan valores extremos (
total_fichas_consultadas,total_sesiones,clicks_por_sesion) principalmente en usuarios no clientes, lo que podría reflejar usuarios ocasionales o errores de registro. - Implicaciones para modelado: Variables con alta separación entre clases son candidatas fuertes para predicción (
total_clicks,total_sesiones,num_dias_sesiones). Variables con baja discriminación pueden ser descartadas o transformadas.
Evaluacion de Asociacion: Pearson T-Test y Chi²
Este apartado evalúa la asociación estadística entre todas las variables predictoras y el objetivo binario (es_cliente), antes de la codificación final. Para las variables categóricas, se aplica el test de Chi-Cuadrado ($ \chi^2 $) para determinar la significancia de la relación, complementado con el coeficiente V de Cramer para medir la fuerza de la asociación. Para las variables numéricas, se utiliza el coeficiente de Correlación de Pearson (equivalente a la correlación punto-biserial) para evaluar la asociación lineal, y un T-Test para determinar si existe una diferencia significativa en las medias de la variable entre los grupos de clientes (1) y no clientes (0). Finalmente, se genera una Matriz de Correlación (Heatmap) para visualizar la interdependencia entre las variables numéricas.
tit("Análisis de Relación Feature-Target (Pre-Codificación) - AMPLIFICADO")
# ----------------------------------------------------
# 1. Definición de Tipos de Variables
# ----------------------------------------------------
# Variables categóricas y cíclicas (a ser analizadas con Chi-cuadrado)
categoricas_a_analizar = [
'canal',
'tipo_usuario',
'bondad_email',
'mes_registro',
'dia_semana_registro',
'dia_mes_registro', # Ahora se trata como Categórica/Cíclica para Chi-cuadrado
'es_finde_registro',
'tiene_fichas'
]
# Variables numéricas restantes (a ser analizadas con Pearson y T-Test)
# Incluye las variables de conteo y ratio en su forma original.
numericas_a_analizar = [
'recencia_fichas',
'antiguedad_comportamiento_fichas',
'usuarios_que_consultan_misma_primera_ficha',
'total_fichas_consultadas',
'total_sesiones',
'total_clicks',
'num_dias_sesiones',
'clicks_por_sesion',
'sesiones_por_dia'
]
# Asegurar que las variables categóricas estén en formato string para el Chi-Cuadrado
for col in categoricas_a_analizar:
if col in df_final.columns:
# Convertimos a string para asegurar que chi2_contingency las trate como categorías
df_final[col] = df_final[col].astype(str)
exito("Tipos de variables definidos y categóricas convertidas a string.")
# ----------------------------------------------------
# 2. Correlación de Pearson (Numéricas vs. Target)
# ----------------------------------------------------
def pearson(df_final,numericas_a_analizar):
tit("Coeficiente de Correlación de Pearson (Numéricas vs. Target)")
# El target es binario (0 o 1). La correlación de Pearson es equivalente a la correlación
# punto-biserial, que es apropiada para medir la asociación lineal.
correlaciones = df_final[numericas_a_analizar + ['es_cliente']].corr()['es_cliente'].drop('es_cliente')
df_correlacion = pd.DataFrame(correlaciones).reset_index()
df_correlacion.columns = ['Variable', 'Correlacion_Pearson']
df_correlacion['Correlacion_Pearson'] = df_correlacion['Correlacion_Pearson'].round(4)
df_correlacion = df_correlacion.sort_values(by='Correlacion_Pearson', ascending=False)
print(df_correlacion.to_markdown(index=False, numalign="left"))
pearson(df_final,numericas_a_analizar)
# ----------------------------------------------------
# 3. Test de Chi-Cuadrado ($\chi^2$) (Categóricas vs. Target)
# ----------------------------------------------------
def chi_cuadrado(df_final,categoricas_a_analizar):
tit("Test de Chi-Cuadrado (Categóricas vs. Target)")
# H0: Las variables son independientes (no hay relación).
# Si p-value < 0.05, rechazamos H0: Hay una relación significativa entre las variables.
resultados_chi2 = []
for col in categoricas_a_analizar:
if col in df_final.columns:
# Crea la tabla de contingencia
tabla_contingencia = pd.crosstab(df_final[col], df_final['es_cliente'])
# Realiza el test de Chi-cuadrado
chi2, p_value, dof, expected = chi2_contingency(tabla_contingencia)
# Calcula el V de Cramer para la fuerza de la asociación
n = tabla_contingencia.values.sum()
# Se asegura que min_dim sea al menos 1 para evitar divisiones por cero si hay categorías vacías
min_dim = max(1, min(tabla_contingencia.shape) - 1)
cramer_v = np.sqrt(chi2 / (n * min_dim))
resultados_chi2.append({
'Variable': col,
'p_value': round(p_value, 6),
'V_Cramer': round(cramer_v, 4),
'Significancia': 'SI (p < 0.05)' if p_value < 0.05 else 'NO'
})
df_chi2 = pd.DataFrame(resultados_chi2).sort_values(by='V_Cramer', ascending=False)
print(df_chi2.to_markdown(index=False, numalign="left"))
chi_cuadrado(df_final,categoricas_a_analizar)
# ----------------------------------------------------
# 4. T-Test (ANOVA para 2 grupos) (Numéricas vs. Target)
# ----------------------------------------------------
def t_test(df_final,numericas_a_analizar):
tit("T-Test de Medias (Numéricas vs. Target)")
# H0: Las medias de la variable son iguales en ambos grupos (es_cliente=0 y es_cliente=1).
# Si p-value < 0.05, rechazamos H0: Hay una diferencia significativa de medias.
resultados_ttest = []
for col in numericas_a_analizar:
if col in df_final.columns:
grupo_0 = df_final[df_final['es_cliente'] == 0][col].dropna()
grupo_1 = df_final[df_final['es_cliente'] == 1][col].dropna()
# Realiza el t-test independiente
stat, p_value = ttest_ind(grupo_0, grupo_1, equal_var=False) # Asume varianzas desiguales
resultados_ttest.append({
'Variable': col,
'p_value': round(p_value, 6),
'Media_Cliente_0': round(grupo_0.mean(), 2),
'Media_Cliente_1': round(grupo_1.mean(), 2),
'Diferencia_Media': round(grupo_1.mean() - grupo_0.mean(), 2),
'Significancia': 'SI (p < 0.05)' if p_value < 0.05 else 'NO'
})
df_ttest = pd.DataFrame(resultados_ttest).sort_values(by='Diferencia_Media', ascending=False)
print(df_ttest.to_markdown(index=False, numalign="left"))
t_test(df_final,numericas_a_analizar)
# ----------------------------------------------------
# 5. Visualización: Matriz de Correlación (Heatmap)
# ----------------------------------------------------
tit("5. Matriz de Correlación (Heatmap)")
# Incluir todas las variables numéricas y el target en la matriz
matriz_corr_vars = numericas_a_analizar + ['es_cliente']
matriz_correlacion = df_final[matriz_corr_vars].corr()
# Generar el heatmap
plt.figure(figsize=(10, 8)) # Aumentar el tamaño para más variables
sns.heatmap(
matriz_correlacion,
annot=True,
fmt=".2f",
cmap='coolwarm',
linewidths=.5,
linecolor='black'
)
plt.title('Matriz de Correlación entre Variables Numéricas y Target', fontsize=14)
plt.show()
==================================================================================================== Análisis de Relación Feature-Target (Pre-Codificación) - AMPLIFICADO ==================================================================================================== ✅ Tipos de variables definidos y categóricas convertidas a string. ==================================================================================================== Coeficiente de Correlación de Pearson (Numéricas vs. Target) ==================================================================================================== | Variable | Correlacion_Pearson | |:-------------------------------------------|:----------------------| | total_fichas_consultadas | 0.0749 | | antiguedad_comportamiento_fichas | 0.05 | | num_dias_sesiones | 0.0376 | | total_sesiones | 0.0235 | | total_clicks | 0.0203 | | clicks_por_sesion | 0.0056 | | sesiones_por_dia | 0.0026 | | usuarios_que_consultan_misma_primera_ficha | -0.0046 | | recencia_fichas | -0.116 | ==================================================================================================== Test de Chi-Cuadrado (Categóricas vs. Target) ==================================================================================================== | Variable | p_value | V_Cramer | Significancia | |:--------------------|:----------|:-----------|:----------------| | tiene_fichas | 0 | 0.1124 | SI (p < 0.05) | | bondad_email | 0 | 0.0645 | SI (p < 0.05) | | tipo_usuario | 0 | 0.0632 | SI (p < 0.05) | | canal | 0 | 0.0605 | SI (p < 0.05) | | dia_mes_registro | 0.711853 | 0.0114 | NO | | mes_registro | 0.154265 | 0.009 | NO | | dia_semana_registro | 0.049735 | 0.008 | SI (p < 0.05) | | es_finde_registro | 0.002002 | 0.007 | SI (p < 0.05) | ==================================================================================================== T-Test de Medias (Numéricas vs. Target) ==================================================================================================== | Variable | p_value | Media_Cliente_0 | Media_Cliente_1 | Diferencia_Media | Significancia | |:-------------------------------------------|:----------|:------------------|:------------------|:-------------------|:----------------| | antiguedad_comportamiento_fichas | 0 | 1.79 | 20.99 | 19.2 | SI (p < 0.05) | | total_fichas_consultadas | 0 | 0.76 | 3.16 | 2.4 | SI (p < 0.05) | | total_clicks | 0 | 4.56 | 5.91 | 1.35 | SI (p < 0.05) | | total_sesiones | 0 | 2.46 | 3.13 | 0.67 | SI (p < 0.05) | | num_dias_sesiones | 0 | 1.08 | 1.28 | 0.2 | SI (p < 0.05) | | sesiones_por_dia | 0.210432 | 2.26 | 2.31 | 0.05 | NO | | clicks_por_sesion | 0.065257 | 1.66 | 1.7 | 0.04 | NO | | usuarios_que_consultan_misma_primera_ficha | 0.002213 | 7.95 | 6.83 | -1.12 | SI (p < 0.05) | | recencia_fichas | 0 | 2008.72 | 1395.14 | -613.58 | SI (p < 0.05) | ==================================================================================================== 5. Matriz de Correlación (Heatmap) ====================================================================================================
📌 Conclusiones de la Evaluación de Asociación (Pre-Codificación)
- Asociación Categórica (Chi-Cuadrado): la relación es significativa para la mayoría de variables.
tiene_fichas(V de Cramer = 0.1124) presenta la mayor fuerza de asociación, seguido porbondad_email,tipo_usuarioycanal. Las variables temporales (dia_mes_registroymes_registro) no muestran significancia. - Asociación Numérica (T-Test): se observan diferencias significativas de medias para métricas de actividad y antigüedad, destacando
antiguedad_comportamiento_fichas(diferencia de 19.2 días) yrecencia_fichas. Las métricas de ratio (sesiones_por_diayclicks_por_sesion) no presentan diferencias significativas. - Correlación Lineal (Pearson): las correlaciones con el target son bajas (por debajo de 0.08 en la mayoría de casos), siendo
recencia_fichasla más relevante (-0.116). - Intercorrelación (Matriz de Correlación): se detecta alta multicolinealidad entre métricas de volumen (e.g.,
total_fichas_consultadas,total_sesiones,total_clicks), por lo que se recomienda eliminar variables redundantes antes del modelado.
➡️ Estrategia de Selección de Features
- Features prioritarios:
tiene_fichas,antiguedad_comportamiento_fichas,recencia_fichas,total_fichas_consultadas,bondad_email,tipo_usuarioycanal. - Features a descartar:
dia_mes_registroymes_registropor nula asociación estadística. - Features bajo evaluación:
sesiones_por_diayclicks_por_sesionpor bajo valor discriminatorio y correlación cercana a cero con el target.
Eliminacion de variables
Eliminación de features no relevantes: se eliminaron las columnas mes_registro y dia_mes_registro y se ajustaron a tipo integer las variables es_finde_registro, tiene_fichas y dia_semana_registro, dejando df_final_modelado listo para la codificación y posterior análisis.
tit("Eliminación de Features No Relevantes")
guardar_csv(df_final,"src\datasets_preproduccion","df_completo.csv")
# Definición de las columnas a eliminar
columnas_a_eliminar = ['mes_registro', 'dia_mes_registro']
df_final_modelado = df_final.copy()
# Verificar que las columnas existan en df_final antes de eliminarlas
columnas_existentes = [col for col in columnas_a_eliminar if col in df_final_modelado.columns]
if columnas_existentes:
# Eliminar las columnas
df_final_modelado = df_final_modelado.drop(columns=columnas_existentes, axis=1)
# Confirmación
exito(f"Columnas eliminadas con éxito: {', '.join(columnas_existentes)}")
print(f"Nuevo número de columnas en df_final: {df_final_modelado.shape[1]}")
else:
print("Las columnas ya han sido eliminadas o no existen en df_final.")
df_final_modelado['es_finde_registro'] = df_final_modelado['es_finde_registro'].astype(int)
df_final_modelado['tiene_fichas'] = df_final_modelado['tiene_fichas'].astype(int)
df_final_modelado['dia_semana_registro'] = df_final_modelado['dia_semana_registro'].astype(int)
exito("transformada a int es_finde_registro, dia_semana_registro y tiene_fichas")
exito("Limpieza de features completada. El df_final_modelado está listo para la codificación final.")
info(df_final_modelado, "DF_FINAL (PRE-ESCALADO)")
==================================================================================================== Eliminación de Features No Relevantes ==================================================================================================== ✅ df_completo.csv guardado en src\datasets_preproduccion\df_completo.csv ✅ Columnas eliminadas con éxito: mes_registro, dia_mes_registro Nuevo número de columnas en df_final: 16 ✅ transformada a int es_finde_registro, dia_semana_registro y tiene_fichas ✅ Limpieza de features completada. El df_final_modelado está listo para la codificación final. ==================================================================================================== 📊 Análisis de estructura y calidad: DF_FINAL (PRE-ESCALADO) ==================================================================================================== Dimensiones: 195165 filas, 16 columnas. Duplicados: 141732 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| canal | object | 195165 | 0 | 0.000000 |
| es_cliente | int64 | 195165 | 0 | 0.000000 |
| bondad_email | object | 195165 | 0 | 0.000000 |
| tipo_usuario | object | 195165 | 0 | 0.000000 |
| dia_semana_registro | int64 | 195165 | 0 | 0.000000 |
| es_finde_registro | int64 | 195165 | 0 | 0.000000 |
| total_fichas_consultadas | int64 | 195165 | 0 | 0.000000 |
| recencia_fichas | int64 | 195165 | 0 | 0.000000 |
| antiguedad_comportamiento_fichas | int64 | 195165 | 0 | 0.000000 |
| total_sesiones | int64 | 195165 | 0 | 0.000000 |
| total_clicks | int64 | 195165 | 0 | 0.000000 |
| num_dias_sesiones | int64 | 195165 | 0 | 0.000000 |
| clicks_por_sesion | float64 | 195165 | 0 | 0.000000 |
| sesiones_por_dia | float64 | 195165 | 0 | 0.000000 |
| usuarios_que_consultan_misma_primera_ficha | Int64 | 195165 | 0 | 0.000000 |
| tiene_fichas | int64 | 195165 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| canal | 195165 | 3 | Directorios | 157004 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| es_cliente | 195165.00 | NaN | NaN | NaN | 0.01 | 0.10 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| bondad_email | 195165 | 5 | 20 | 128118 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| tipo_usuario | 195165 | 2 | PF | 164203 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| dia_semana_registro | 195165.00 | NaN | NaN | NaN | 2.49 | 1.78 | 0.00 | 1.00 | 2.00 | 4.00 | 6.00 |
| es_finde_registro | 195165.00 | NaN | NaN | NaN | 0.15 | 0.36 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| total_fichas_consultadas | 195165.00 | NaN | NaN | NaN | 0.78 | 3.21 | 0.00 | 0.00 | 0.00 | 1.00 | 433.00 |
| recencia_fichas | 195165.00 | NaN | NaN | NaN | 2002.49 | 530.65 | 0.00 | 1304.00 | 2306.00 | 2306.00 | 2306.00 |
| antiguedad_comportamiento_fichas | 195165.00 | NaN | NaN | NaN | 1.99 | 38.53 | -1.00 | -1.00 | -1.00 | 0.00 | 1246.00 |
| total_sesiones | 195165.00 | NaN | NaN | NaN | 2.47 | 2.87 | 0.00 | 2.00 | 2.00 | 2.00 | 398.00 |
| total_clicks | 195165.00 | NaN | NaN | NaN | 4.57 | 6.65 | 0.00 | 3.00 | 3.00 | 4.00 | 772.00 |
| num_dias_sesiones | 195165.00 | NaN | NaN | NaN | 1.08 | 0.53 | 0.00 | 1.00 | 1.00 | 1.00 | 106.00 |
| clicks_por_sesion | 195165.00 | NaN | NaN | NaN | 1.67 | 0.66 | 0.00 | 1.50 | 1.50 | 1.67 | 37.00 |
| sesiones_por_dia | 195165.00 | NaN | NaN | NaN | 2.26 | 1.95 | 0.00 | 2.00 | 2.00 | 2.00 | 310.00 |
| usuarios_que_consultan_misma_primera_ficha | 195165.00 | <NA> | <NA> | <NA> | 7.93 | 24.32 | 0.00 | 1.00 | 2.00 | 5.00 | 324.00 |
| tiene_fichas | 195165.00 | NaN | NaN | NaN | 0.25 | 0.43 | 0.00 | 0.00 | 0.00 | 1.00 | 1.00 |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| canal | es_cliente | bondad_email | tipo_usuario | dia_semana_registro | es_finde_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | total_clicks | num_dias_sesiones | clicks_por_sesion | sesiones_por_dia | usuarios_que_consultan_misma_primera_ficha | tiene_fichas | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Directorios | 0 | 9 | PF | 0 | 0 | 0 | 2306 | -1 | 1 | 1 | 1 | 1.00 | 1.00 | 0 | 0 |
| 1 | Directorios | 0 | 20 | PF | 0 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 |
| 2 | Directorios | 0 | 20 | PF | 0 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 |
----------------------------------------------------------------------------------------------------
Preparacion del dataset para el modelado
Se procederá a la preparación del dataset con el objetivo de abarcar la mayor diversidad de modelos posible.
Para ello, se generarán dos conjuntos de datos: uno genérico y otro más específico, optimizado para modelos lineales mediante escalado y descomposición en componentes principales (PCA). Este proceso requiere la codificación de las variables categóricas. Según investigaciones previas, los modelos más prometedores son aquellos basados en boosting, bagging y árboles, aunque también se evaluarán modelos lineales para complementar el estudio.
En esta fase inicial no se considera conveniente discretizar las variables; esta decisión se revisará en función de los resultados obtenidos. La discretización podría ser beneficiosa únicamente para mejorar el desempeño de los modelos lineales frente a relaciones no lineales, aunque, a priori, se espera que los modelos lineales tengan un rendimiento limitado en este contexto.
Codificacion de variables categóricas
Codificación y preparación del dataset final: se aplicó One-Hot Encoding a las variables categóricas nominales y cíclicas, eliminando manualmente columnas de referencia para reducir colinealidad. El DataFrame resultante queda listo para el modelado, conservando únicamente las variables numéricas relevantes.
tit("Codificación y Discretización del DataFrame Final")
# ----------------------------------------------------
# 1. One-Hot Encoding (OHE)
# ----------------------------------------------------
# Variables categóricas nominales y cíclicas a codificar
columnas_ohe = [
'canal',
'tipo_usuario',
'bondad_email',
]
# Las columnas numéricas o enteras deben convertirse a 'object' antes de OHE
# para que get_dummies las trate como categorías (ya realizado previamente)
# df_final['dia_semana_registro'] = df_final['dia_semana_registro'].astype(str)
# df_final['bondad_email'] = df_final['bondad_email'].astype(str)
# Crear dummies y eliminar la primera columna para evitar colinealidad (drop_first=True)
df_final_codificado = df_final_modelado.copy()
df_final_codificado = pd.get_dummies(df_final_codificado, columns=columnas_ohe, prefix=columnas_ohe, drop_first=False) #Drop manual
exito("One-Hot Encoding aplicado a todas las variables nominales, cíclicas y discretizadas.")
# Se eliminan las columnas de referencia manualmente para ajustar adecuadamente luego en VIF
cols_ref = ['bondad_email_9', 'tipo_usuario_PJ', 'canal_Directorios']
exito(f"Se establece manualmente columnas de referencia para mejorar colinealidad:\n usando {cols_ref}")
df_final_codificado = df_final_codificado.drop(columns=cols_ref, axis=1)
# ----------------------------------------------------
# 2. Revisión Final del DataFrame para Modelado
# ----------------------------------------------------
tit("DataFrame Final Listo para Modelado")
# Las únicas variables numéricas que quedan son:
# recencia_fichas, antiguedad_comportamiento_fichas, usuarios_que_consultan_misma_primera_ficha,
# es_cliente, es_finde_registro.
info(df_final_codificado, "DF FINAL CODIFICADO")
print(f"El DataFrame final tiene {df_final_codificado.shape[1]} columnas (features).")
==================================================================================================== Codificación y Discretización del DataFrame Final ==================================================================================================== ✅ One-Hot Encoding aplicado a todas las variables nominales, cíclicas y discretizadas. ✅ Se establece manualmente columnas de referencia para mejorar colinealidad: usando ['bondad_email_9', 'tipo_usuario_PJ', 'canal_Directorios'] ==================================================================================================== DataFrame Final Listo para Modelado ==================================================================================================== ==================================================================================================== 📊 Análisis de estructura y calidad: DF FINAL CODIFICADO ==================================================================================================== Dimensiones: 195165 filas, 20 columnas. Duplicados: 141732 ---------------------------------------------------------------------------------------------------- Tipos de datos y valores no-nulos:
| Dtype | No-Nulos | Nulos | % Nulos | |
|---|---|---|---|---|
| es_cliente | int64 | 195165 | 0 | 0.000000 |
| dia_semana_registro | int64 | 195165 | 0 | 0.000000 |
| es_finde_registro | int64 | 195165 | 0 | 0.000000 |
| total_fichas_consultadas | int64 | 195165 | 0 | 0.000000 |
| recencia_fichas | int64 | 195165 | 0 | 0.000000 |
| antiguedad_comportamiento_fichas | int64 | 195165 | 0 | 0.000000 |
| total_sesiones | int64 | 195165 | 0 | 0.000000 |
| total_clicks | int64 | 195165 | 0 | 0.000000 |
| num_dias_sesiones | int64 | 195165 | 0 | 0.000000 |
| clicks_por_sesion | float64 | 195165 | 0 | 0.000000 |
| sesiones_por_dia | float64 | 195165 | 0 | 0.000000 |
| usuarios_que_consultan_misma_primera_ficha | Int64 | 195165 | 0 | 0.000000 |
| tiene_fichas | int64 | 195165 | 0 | 0.000000 |
| canal_SEM | bool | 195165 | 0 | 0.000000 |
| canal_SEO | bool | 195165 | 0 | 0.000000 |
| tipo_usuario_PF | bool | 195165 | 0 | 0.000000 |
| bondad_email_-10 | bool | 195165 | 0 | 0.000000 |
| bondad_email_0 | bool | 195165 | 0 | 0.000000 |
| bondad_email_1 | bool | 195165 | 0 | 0.000000 |
| bondad_email_20 | bool | 195165 | 0 | 0.000000 |
---------------------------------------------------------------------------------------------------- Estadísticas Descriptivas (Numéricas y Categóricas):
| count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| es_cliente | 195165.00 | NaN | NaN | NaN | 0.01 | 0.10 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| dia_semana_registro | 195165.00 | NaN | NaN | NaN | 2.49 | 1.78 | 0.00 | 1.00 | 2.00 | 4.00 | 6.00 |
| es_finde_registro | 195165.00 | NaN | NaN | NaN | 0.15 | 0.36 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
| total_fichas_consultadas | 195165.00 | NaN | NaN | NaN | 0.78 | 3.21 | 0.00 | 0.00 | 0.00 | 1.00 | 433.00 |
| recencia_fichas | 195165.00 | NaN | NaN | NaN | 2002.49 | 530.65 | 0.00 | 1304.00 | 2306.00 | 2306.00 | 2306.00 |
| antiguedad_comportamiento_fichas | 195165.00 | NaN | NaN | NaN | 1.99 | 38.53 | -1.00 | -1.00 | -1.00 | 0.00 | 1246.00 |
| total_sesiones | 195165.00 | NaN | NaN | NaN | 2.47 | 2.87 | 0.00 | 2.00 | 2.00 | 2.00 | 398.00 |
| total_clicks | 195165.00 | NaN | NaN | NaN | 4.57 | 6.65 | 0.00 | 3.00 | 3.00 | 4.00 | 772.00 |
| num_dias_sesiones | 195165.00 | NaN | NaN | NaN | 1.08 | 0.53 | 0.00 | 1.00 | 1.00 | 1.00 | 106.00 |
| clicks_por_sesion | 195165.00 | NaN | NaN | NaN | 1.67 | 0.66 | 0.00 | 1.50 | 1.50 | 1.67 | 37.00 |
| sesiones_por_dia | 195165.00 | NaN | NaN | NaN | 2.26 | 1.95 | 0.00 | 2.00 | 2.00 | 2.00 | 310.00 |
| usuarios_que_consultan_misma_primera_ficha | 195165.00 | <NA> | <NA> | <NA> | 7.93 | 24.32 | 0.00 | 1.00 | 2.00 | 5.00 | 324.00 |
| tiene_fichas | 195165.00 | NaN | NaN | NaN | 0.25 | 0.43 | 0.00 | 0.00 | 0.00 | 1.00 | 1.00 |
| canal_SEM | 195165 | 2 | False | 181861 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| canal_SEO | 195165 | 2 | False | 170308 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| tipo_usuario_PF | 195165 | 2 | True | 164203 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| bondad_email_-10 | 195165 | 2 | False | 193757 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| bondad_email_0 | 195165 | 2 | False | 190910 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| bondad_email_1 | 195165 | 2 | False | 195125 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| bondad_email_20 | 195165 | 2 | True | 128118 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
---------------------------------------------------------------------------------------------------- Primeras 3 filas (df.head(3)):
| es_cliente | dia_semana_registro | es_finde_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | total_clicks | num_dias_sesiones | clicks_por_sesion | sesiones_por_dia | usuarios_que_consultan_misma_primera_ficha | tiene_fichas | canal_SEM | canal_SEO | tipo_usuario_PF | bondad_email_-10 | bondad_email_0 | bondad_email_1 | bondad_email_20 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 2306 | -1 | 1 | 1 | 1 | 1.00 | 1.00 | 0 | 0 | False | False | True | False | False | False | False |
| 1 | 0 | 0 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 | False | False | True | False | False | False | True |
| 2 | 0 | 0 | 0 | 0 | 2306 | -1 | 2 | 3 | 1 | 1.50 | 2.00 | 2 | 0 | False | False | True | False | False | False | True |
---------------------------------------------------------------------------------------------------- El DataFrame final tiene 20 columnas (features).
Creación del dataset de uso genérico
Se procurará minimizar la colinealidad en el dataset mediante la eliminación de variables cuando sea necesario. Aunque esto podría implicar la pérdida de información no lineal potencialmente útil, se garantiza que un mayor número de modelos podrá operar adecuadamente con este conjunto de datos.
Análisis de colinealidad
tit("Análisis de Colinealidad (VIF) Post-OHE")
# ----------------------------------------------------
# 1. Preparación de los datos
# ----------------------------------------------------
def calcula_vif(df):
# Identificar las columnas de las variables predictoras (X)
# Excluimos la variable target ('es_cliente') y la constante si ya estuviera
X = df.drop(columns=['es_cliente'], errors='ignore')
# ----------------------------------------------------
# 2. Agregar una constante (Intercepto)
# ----------------------------------------------------
# El cálculo de VIF requiere que la matriz de diseño contenga una constante (el intercepto).
# Esto es crucial para un análisis VIF correcto en modelos de regresión.
try:
X_vif = add_constant(X, prepend=True)
except ValueError:
# Si 'const' ya existe (raro tras drop/OHE), continuamos
X_vif = X.copy()
if 'const' not in X_vif.columns:
X_vif = add_constant(X, prepend=True)
# ----------------------------------------------------
# 3. Cálculo del VIF
# ----------------------------------------------------
# La función requiere que todos los datos sean float
X_vif = X_vif.astype(float)
vif_data = pd.DataFrame()
vif_data["feature"] = X_vif.columns
vif_data["VIF"] = [variance_inflation_factor(X_vif.values, i) for i in range(len(X_vif.columns))]
# Redondear y ordenar
vif_data['VIF'] = vif_data['VIF'].round(2)
vif_data = vif_data.sort_values(by='VIF', ascending=False)
# ----------------------------------------------------
# 4. Presentación de Resultados
# ----------------------------------------------------
print("Resultados del Factor de Inflación de Varianza (VIF)")
print("\n")
print(vif_data.to_markdown(index=False, numalign="left"))
calcula_vif(df_final_codificado)
==================================================================================================== Análisis de Colinealidad (VIF) Post-OHE ==================================================================================================== Resultados del Factor de Inflación de Varianza (VIF) | feature | VIF | |:-------------------------------------------|:--------| | const | 1250.65 | | recencia_fichas | 63.03 | | tiene_fichas | 62.59 | | total_clicks | 30.92 | | total_sesiones | 27.85 | | total_fichas_consultadas | 10.08 | | sesiones_por_dia | 5.72 | | num_dias_sesiones | 3.84 | | clicks_por_sesion | 2.97 | | dia_semana_registro | 1.95 | | es_finde_registro | 1.95 | | antiguedad_comportamiento_fichas | 1.41 | | canal_SEM | 1.29 | | canal_SEO | 1.21 | | bondad_email_20 | 1.14 | | bondad_email_0 | 1.1 | | bondad_email_-10 | 1.03 | | usuarios_que_consultan_misma_primera_ficha | 1.02 | | tipo_usuario_PF | 1 | | bondad_email_1 | 1 |
df_test = df_final_codificado.copy()
df_test = df_test.drop('recencia_fichas', axis = 1)
df_test = df_test.drop('total_clicks', axis = 1)
df_test = df_test.drop('total_sesiones', axis = 1)
exito("Eliminado en df_test recencia_fichas, total_clicks, total_sesiones")
calcula_vif(df_test)
✅ Eliminado en df_test recencia_fichas, total_clicks, total_sesiones Resultados del Factor de Inflación de Varianza (VIF) | feature | VIF | |:-------------------------------------------|:------| | const | 32.84 | | total_fichas_consultadas | 3.69 | | sesiones_por_dia | 2.66 | | num_dias_sesiones | 2.23 | | es_finde_registro | 1.95 | | dia_semana_registro | 1.95 | | tiene_fichas | 1.69 | | clicks_por_sesion | 1.36 | | canal_SEM | 1.25 | | canal_SEO | 1.17 | | bondad_email_20 | 1.13 | | antiguedad_comportamiento_fichas | 1.13 | | bondad_email_0 | 1.08 | | bondad_email_-10 | 1.02 | | usuarios_que_consultan_misma_primera_ficha | 1.02 | | tipo_usuario_PF | 1 | | bondad_email_1 | 1 |
📌 Conclusiones
- Multicolinealidad significativa en variables de comportamiento:
recencia_fichas(VIF = 63.03),tiene_fichas(62.59),total_clicks(30.92),total_sesiones(27.85).
- El intercepto presentó un VIF muy alto (1250.65), fenómeno típico en presencia de fuerte colinealidad entre predictores o ausencia de centrado; su valor no es interpretativo, pero indica un problema global de multicolinealidad.
- Las variables categóricas tras la codificación One-Hot Encoding muestran VIF entre 1 y 1.3 (p.ej.,
canal_SEM,canal_SEO,bondad_email_*), confirmando una codificación correcta y la ausencia del “dummy variable trap”.
Se eliminaron del modelo las variables recencia_fichas, total_clicks y total_sesiones para reducir la colinealidad.
| Indicador | Antes | Después | Observación |
|---|---|---|---|
| Máximo VIF (excluyendo intercepto) | 63.03 (recencia_fichas) |
3.69 (total_fichas_consultadas) |
Multicolinealidad severa reducida a niveles aceptables (<5). |
| Intercepto (const) | 1250.65 | 32.84 | Descenso notable; valor aún alto pero no interpretativo para VIF, mitigable mediante centrado o estandarización. |
| Variables de actividad restantes | VIF hasta ~30 | VIF 2.23–3.69 | sesiones_por_dia, num_dias_sesiones, total_fichas_consultadas presentan colinealidad moderada. |
| Categóricas (OHE) | ≈1–1.3 | ≈1–1.3 | Se mantienen estables y correctamente condicionadas. |
Guardar dataset genérico
# ----------------------------------------------------------------------
# 2. Guardar el archivo
# ----------------------------------------------------------------------
df_preproc = df_test.copy()
guardar_csv(df_preproc,"src\datasets_preproduccion","df_preproc.csv")
✅ df_preproc.csv guardado en src\datasets_preproduccion\df_preproc.csv
df_preproc.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 195165 entries, 0 to 195164 Data columns (total 17 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 es_cliente 195165 non-null int64 1 dia_semana_registro 195165 non-null int64 2 es_finde_registro 195165 non-null int64 3 total_fichas_consultadas 195165 non-null int64 4 antiguedad_comportamiento_fichas 195165 non-null int64 5 num_dias_sesiones 195165 non-null int64 6 clicks_por_sesion 195165 non-null float64 7 sesiones_por_dia 195165 non-null float64 8 usuarios_que_consultan_misma_primera_ficha 195165 non-null Int64 9 tiene_fichas 195165 non-null int64 10 canal_SEM 195165 non-null bool 11 canal_SEO 195165 non-null bool 12 tipo_usuario_PF 195165 non-null bool 13 bondad_email_-10 195165 non-null bool 14 bondad_email_0 195165 non-null bool 15 bondad_email_1 195165 non-null bool 16 bondad_email_20 195165 non-null bool dtypes: Int64(1), bool(7), float64(2), int64(7) memory usage: 16.4 MB
Creación de dataset para modelos lineales: PCA
Se preparó un conjunto de datos optimizado para modelos lineales mediante estandarización de variables numéricas y centrado de variables booleanas. A continuación, se aplicó Análisis de Componentes Principales (PCA) para reducir la dimensionalidad manteniendo el 95% de la varianza total. El dataset resultante permite representar las características originales en un espacio reducido de componentes principales, facilitando el entrenamiento de modelos lineales y el análisis de la contribución de cada variable original a las nuevas dimensiones.
#modificamos todas binarias a bool
df_final_codificado["tiene_fichas"] = df_final_codificado["tiene_fichas"].astype(bool)
df_final_codificado["es_finde_registro"] = df_final_codificado["es_finde_registro"].astype(bool)
import pandas as pd
import numpy as np
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
# ============================================================================
# 1. PREPARAR DATOS - ESTRATEGIA DIFERENCIADA
# ============================================================================
def preparar_datasets_pca(df_codificado):
"""
Prepara dos versiones: una para PCA (lineales) y otra para árboles
"""
print("🔧 Preparando datasets para diferentes tipos de modelos...")
# Separar target
if 'es_cliente' in df_codificado.columns:
y = df_codificado['es_cliente']
X = df_codificado.drop('es_cliente', axis=1)
else:
X = df_codificado.copy()
y = None
# Identificar tipos de variables
numeric_cols = X.select_dtypes(include=[np.number]).columns.tolist()
bool_cols = X.select_dtypes(include=['bool']).columns.tolist()
print(f" Variables numéricas: {len(numeric_cols)}")
print(f" Variables booleanas (one-hot): {len(bool_cols)}")
# ==============================================
# B) DATASET PARA PCA + MODELOS LINEALES
# ==============================================
print("\n📐 Dataset para PCA + modelos lineales:")
# Estrategia: escalado diferente para numéricas vs booleanas
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
# Para PCA necesitamos:
# - Numéricas: Estandarizar (mean=0, std=1)
# - Booleanas: Solo centrar (restar media), NO escalar varianza
preprocessor = ColumnTransformer([
('numeric', StandardScaler(), numeric_cols),
('bool', StandardScaler(with_std=False), bool_cols) # Solo centrar
])
# Aplicar transformación
X_scaled = preprocessor.fit_transform(X)
# Crear DataFrame
scaled_columns = numeric_cols + bool_cols
df_pca_ready = pd.DataFrame(X_scaled, columns=scaled_columns, index=X.index)
if y is not None:
df_pca_ready['es_cliente'] = y.values
print(f" - Shape: {df_pca_ready.shape}")
print(" - Numéricas: escaladas (StandardScaler)")
print(" - Booleanas: centradas (solo restar media)")
return {
'df_pca_ready': df_pca_ready, # Para PCA + modelos lineales
'preprocessor': preprocessor # Para aplicar a nuevos datos
}
# ============================================================================
# 2. APLICAR PCA
# ============================================================================
def aplicar_pca_analisis(df_pca_ready, varianza_objetivo=0.95):
"""
Aplica PCA y analiza resultados
"""
# Separar features (excluir target si existe)
if 'es_cliente' in df_pca_ready.columns:
X = df_pca_ready.drop('es_cliente', axis=1)
y = df_pca_ready['es_cliente']
else:
X = df_pca_ready.copy()
y = None
print(f"\n🎯 Aplicando PCA a dataset de {X.shape[1]} variables...")
# 1. PCA para análisis (todos los componentes)
pca_full = PCA()
X_pca_full = pca_full.fit_transform(X)
# 2. Calcular varianza explicada
varianza_explicada = pca_full.explained_variance_ratio_
varianza_acumulada = np.cumsum(varianza_explicada)
# 3. Encontrar componentes necesarios para varianza_objetivo
n_componentes = np.argmax(varianza_acumulada >= varianza_objetivo) + 1
print(f"\n📊 Resultados PCA:")
print(f" - Total componentes posibles: {len(varianza_explicada)}")
print(f" - Componentes para {varianza_objetivo*100}% varianza: {n_componentes}")
print(f" - Varianza explicada por PC1: {varianza_explicada[0]*100:.1f}%")
print(f" - Varianza explicada por PC2: {varianza_explicada[1]*100:.1f}%")
# 4. PCA con componentes reducidos
pca_reducido = PCA(n_components=n_componentes)
X_pca_reducido = pca_reducido.fit_transform(X)
# 5. Crear DataFrame con componentes
columnas_pca = [f'PC{i+1}' for i in range(n_componentes)]
df_pca_resultado = pd.DataFrame(X_pca_reducido, columns=columnas_pca, index=X.index)
if y is not None:
df_pca_resultado['es_cliente'] = y.values
print(f"\n✅ Dataset transformado: {df_pca_resultado.shape}")
print(f" {X.shape[1]} variables → {n_componentes} componentes principales")
return {
'df_pca': df_pca_resultado,
'pca_full': pca_full,
'pca_reducido': pca_reducido,
'varianza_explicada': varianza_explicada,
'varianza_acumulada': varianza_acumulada,
'n_componentes': n_componentes
}
# ============================================================================
# 3. VISUALIZAR RESULTADOS PCA
# ============================================================================
def visualizar_pca(resultados_pca):
"""
Gráficos simples para entender PCA
"""
varianza_explicada = resultados_pca['varianza_explicada']
varianza_acumulada = resultados_pca['varianza_acumulada']
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 1. Scree plot (varianza por componente)
axes[0].bar(range(1, len(varianza_explicada)+1), varianza_explicada[:20], alpha=0.7)
axes[0].set_xlabel('Componente Principal')
axes[0].set_ylabel('Varianza Explicada')
axes[0].set_title('Varianza por Componente (Scree Plot)')
axes[0].grid(True, alpha=0.3)
# 2. Varianza acumulada
axes[1].plot(range(1, len(varianza_acumulada)+1), varianza_acumulada,
marker='o', linestyle='-', color='b')
axes[1].axhline(y=0.95, color='r', linestyle='--', alpha=0.7, label='95% varianza')
axes[1].axhline(y=0.90, color='g', linestyle='--', alpha=0.7, label='90% varianza')
axes[1].set_xlabel('Número de Componentes')
axes[1].set_ylabel('Varianza Acumulada')
axes[1].set_title('Varianza Acumulada')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# 3. Mostrar cuántos componentes para cada nivel de varianza
print("\n📈 Componentes necesarios para diferentes niveles de varianza:")
for umbral in [0.80, 0.85, 0.90, 0.95, 0.99]:
n_comp = np.argmax(varianza_acumulada >= umbral) + 1
print(f" {umbral*100:.0f}% varianza: {n_comp} componentes")
# ============================================================================
# 4. ANÁLISIS DE LOADINGS (qué variables contribuyen)
# ============================================================================
def analizar_loadings(pca_model, feature_names, n_top=10):
"""
Analiza qué variables originales contribuyen a cada componente
"""
loadings = pca_model.components_
print("\n🔍 Variables que más contribuyen a cada componente:")
for i in range(min(3, loadings.shape[0])): # Primeros 3 componentes
# Obtener índices de las variables con mayor contribución (absoluta)
indices_top = np.argsort(np.abs(loadings[i]))[-n_top:]
print(f"\n📊 PC{i+1} (Top {n_top} variables):")
for idx in reversed(indices_top):
var_name = feature_names[idx]
loading_val = loadings[i, idx]
print(f" {var_name:40} : {loading_val:+.3f}")
# Matriz de loadings simplificada
print("\n📋 Matriz de Loadings (primeras 3 componentes):")
loadings_df = pd.DataFrame(
loadings[:3, :10].T, # Primeras 3 PCs, primeras 10 variables
columns=[f'PC{i+1}' for i in range(3)],
index=feature_names[:10]
)
print(loadings_df.round(3))
# ============================================================================
# 5. CÓDIGO COMPLETO PARA TU TFM
# ============================================================================
def pipeline_pca_completo(df_codificado, varianza_objetivo=0.95):
"""
Pipeline completo: preparación + PCA + análisis
"""
print("="*70)
print("🚀 PIPELINE COMPLETO PCA")
print("="*70)
# 1. Preparar datasets diferenciados
datasets = preparar_datasets_pca(df_codificado)
# 2. Aplicar PCA al dataset preparado
resultados_pca = aplicar_pca_analisis(
datasets['df_pca_ready'],
varianza_objetivo=varianza_objetivo
)
# 3. Visualizar
visualizar_pca(resultados_pca)
# 4. Analizar loadings
if 'es_cliente' in df_codificado.columns:
feature_names = df_codificado.drop('es_cliente', axis=1).columns.tolist()
else:
feature_names = df_codificado.columns.tolist()
analizar_loadings(resultados_pca['pca_full'], feature_names)
# 5. Retornar todo
return {
'datasets': datasets,
'pca_resultados': resultados_pca,
'df_pca_transformado': resultados_pca['df_pca']
}
# ============================================================================
# EJECUCIÓN
# ============================================================================
# Suponiendo que df_final_codificado es tu dataset
resultados = pipeline_pca_completo(df_final_codificado, varianza_objetivo=0.95)
# Acceder a los resultados:
tit("📁 DATASETS RESULTANTES:")
df_PCA = resultados['df_pca_transformado']
guardar_csv(df_PCA,"src\datasets_preproduccion","df_PCA.csv")
print(f"\n2. Para modelos lineales (con PCA aplicado):")
print(f" - Shape: {resultados['df_pca_transformado'].shape}")
print(f" - {resultados['pca_resultados']['n_componentes']} componentes principales")
print(f" - Varianza conservada: {resultados['pca_resultados']['varianza_acumulada'][resultados['pca_resultados']['n_componentes']-1]*100:.1f}%")
====================================================================== 🚀 PIPELINE COMPLETO PCA ====================================================================== 🔧 Preparando datasets para diferentes tipos de modelos... Variables numéricas: 10 Variables booleanas (one-hot): 9 📐 Dataset para PCA + modelos lineales: - Shape: (195165, 20) - Numéricas: escaladas (StandardScaler) - Booleanas: centradas (solo restar media) 🎯 Aplicando PCA a dataset de 19 variables... 📊 Resultados PCA: - Total componentes posibles: 19 - Componentes para 95.0% varianza: 9 - Varianza explicada por PC1: 39.6% - Varianza explicada por PC2: 12.0% ✅ Dataset transformado: (195165, 10) 19 variables → 9 componentes principales
📈 Componentes necesarios para diferentes niveles de varianza:
80% varianza: 5 componentes
85% varianza: 6 componentes
90% varianza: 7 componentes
95% varianza: 9 componentes
99% varianza: 13 componentes
🔍 Variables que más contribuyen a cada componente:
📊 PC1 (Top 10 variables):
total_sesiones : +0.466
antiguedad_comportamiento_fichas : +0.453
es_finde_registro : +0.448
clicks_por_sesion : +0.364
total_fichas_consultadas : -0.297
total_clicks : +0.267
num_dias_sesiones : +0.229
tiene_fichas : +0.127
recencia_fichas : +0.116
canal_SEO : +0.036
📊 PC2 (Top 10 variables):
total_clicks : +0.585
recencia_fichas : +0.572
num_dias_sesiones : -0.421
clicks_por_sesion : -0.338
total_fichas_consultadas : +0.114
antiguedad_comportamiento_fichas : +0.093
tiene_fichas : -0.072
es_finde_registro : +0.065
total_sesiones : -0.064
canal_SEM : -0.036
📊 PC3 (Top 10 variables):
total_fichas_consultadas : +0.583
num_dias_sesiones : -0.458
recencia_fichas : -0.387
antiguedad_comportamiento_fichas : +0.271
tiene_fichas : -0.242
clicks_por_sesion : +0.237
es_finde_registro : +0.202
dia_semana_registro : +0.153
total_sesiones : +0.143
sesiones_por_dia : +0.142
📋 Matriz de Loadings (primeras 3 componentes):
PC1 PC2 PC3
dia_semana_registro -0.010 0.034 0.153
es_finde_registro 0.448 0.065 0.202
total_fichas_consultadas -0.297 0.114 0.583
recencia_fichas 0.116 0.572 -0.387
antiguedad_comportamiento_fichas 0.453 0.093 0.271
total_sesiones 0.466 -0.064 0.143
total_clicks 0.267 0.585 -0.021
num_dias_sesiones 0.229 -0.421 -0.458
clicks_por_sesion 0.364 -0.338 0.237
sesiones_por_dia -0.009 -0.004 0.142
====================================================================================================
📁 DATASETS RESULTANTES:
====================================================================================================
✅ df_PCA.csv guardado en src\datasets_preproduccion\df_PCA.csv
2. Para modelos lineales (con PCA aplicado):
- Shape: (195165, 10)
- 9 componentes principales
- Varianza conservada: 95.9%
📌Conclusiones PCA
- El dataset final para PCA contiene 9 componentes principales que explican el 95.9% de la varianza de las 19 variables originales.
- PC1 está dominada por
total_sesiones,antiguedad_comportamiento_fichasyes_finde_registro, mientras que PC2 refleja principalmentetotal_clicksyrecencia_fichas. - Las primeras tres componentes resumen adecuadamente la información de actividad y comportamiento de los usuarios, permitiendo reducir dimensionalidad para modelos lineales sin pérdida significativa de información.
- El dataset transformado (
df_PCA) queda listo para entrenamiento de modelos lineales o análisis complementario con menor riesgo de multicolinealidad.
Guardar dataset PCA
df_PCA = resultados['df_pca_transformado']
Generación de conjuntos de entrenamiento y validación
El código realiza una división estratificada del dataset en conjuntos de entrenamiento/validación (90%) y prueba (10%), manteniendo la proporción de clases de la variable objetivo es_cliente. Esto asegura que ambos conjuntos representen fielmente la distribución original de la clase, facilitando un entrenamiento y evaluación consistentes de los modelos.
Esta división estratificada se utilizará de manera consistente a lo largo de todo el proyecto para separar conjuntos de entrenamiento/validación y prueba. Al mantener las proporciones de la variable objetivo y no reutilizar los datos de prueba durante el entrenamiento, se minimiza el riesgo de data leakage, garantizando evaluaciones imparciales y representativas del rendimiento real de los modelos.
from sklearn.model_selection import train_test_split
def create_stratified_splits(df: pd.DataFrame, target_col: str ="es_cliente", test_size: float =0.10, random_state: int=42):
"""
Divide el DataFrame en conjuntos de Train/Validation (90%) y Test (10%) de forma estratificada.
Args:
df (pd.DataFrame): DataFrame completo de entrada.
target_col (str): Nombre de la columna objetivo para la estratificación.
test_size (float): Proporción de datos a reservar para el conjunto de prueba (Test).
random_state (int): Semilla para la reproducibilidad de la división.
Returns:
tuple: (df_train_val, df_test), los DataFrames divididos.
"""
print(f"--- Iniciando división de datos ({len(df)} registros)\ntarget col y variable estratificada = \"{target_col}\" random state = {random_state}, test size = {test_size*100}% ---")
# Extraer la variable objetivo (Y)
if target_col not in df.columns:
print(f"ERROR: La columna objetivo '{target_col}' no se encuentra en el DataFrame.")
sys.exit(1)
X = df.drop(columns=[target_col])
y = df[target_col]
# 1. División inicial: Test (10%) y Train/Val (90%)
# Estratificación por 'y' para asegurar proporciones iguales de la clase objetivo.
X_train_val, X_test, y_train_val, y_test = train_test_split(
X, y,
test_size=test_size,
random_state=random_state,
stratify=y # Clave para la estratificación
)
# Reconstruir los DataFrames completos
#df_train_val = pd.concat([X_train_val, y_train_val], axis=1)
#df_test = pd.concat([X_test, y_test], axis=1)
# 2. Impresión de resultados para verificación
print("\n--- Distribución de Clases (Verificación) ---")
# Distribución en el DataFrame original
original_dist = y.value_counts(normalize=True) * 100
print(f"Original ({len(df)}):")
print(original_dist.to_string(float_format="%.2f%%"))
# Distribución en Train/Val
train_val_dist = y_train_val.value_counts(normalize=True) * 100
print(f"\nTrain/Validation ({len(X_train_val)}):")
print(train_val_dist.to_string(float_format="%.2f%%"))
# Distribución en Test
test_dist = y_test.value_counts(normalize=True) * 100
print(f"\nTest ({len(X_test)}):")
print(test_dist.to_string(float_format="%.2f%%"))
# Comprobación de que las proporciones son casi idénticas
is_stratified = np.allclose(original_dist, train_val_dist, atol=0.01) and \
np.allclose(original_dist, test_dist, atol=0.01)
if is_stratified:
print("\n✅ Verificación: La división fue exitosa y estratificada.")
else:
print("\n⚠️ Advertencia: Las proporciones de clase difieren más de lo esperado.")
return X_train_val, X_test, y_train_val, y_test
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_preproc)
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_PCA)
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada. --- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada.
Diseño y optimización del sistema predictivo
Planteamiento general del problema de modelado
El problema abordado en este trabajo se formula como una tarea de clasificación binaria orientada a estimar la probabilidad de que un usuario realice una compra en un entorno de comercio electrónico. La literatura reciente muestra que los modelos de aprendizaje automático aplicados a señales de comportamiento digital permiten identificar patrones de interacción altamente informativos para la conversión (Gkikas, 2024; Zamora Pérez, 2025).
Este tipo de problemas se caracteriza habitualmente por un alto desbalance de clases, donde la proporción de compradores es muy reducida respecto al total de usuarios, lo que introduce retos adicionales en la selección de modelos, métricas de evaluación y estrategias de entrenamiento. Trabajos recientes destacan la necesidad de abordar explícitamente este desbalance mediante técnicas de muestreo, modelos robustos y métricas orientadas a la clase minoritaria (de Vargas, 2022; Al-Ebrahim et al., 2024).
En este contexto, la predicción de la intención de compra se apoya principalmente en señales de interacción y engagement, más que en atributos estáticos del usuario, reforzando la importancia de enfoques centrados en el comportamiento observado y su evolución temporal (Hesvindrati, 2025).
Desde una perspectiva de negocio, el objetivo del modelado no se limita a maximizar la exactitud global, sino a identificar de forma eficaz a los compradores potenciales, priorizando la capacidad del modelo para capturar la clase minoritaria sin generar un volumen excesivo de falsas alarmas. Por este motivo, el proceso se orienta principalmente a optimizar métricas sensibles al desbalance, como PR-AUC y orientadas a negocio como recall y lift, así como a evaluar el comportamiento del modelo como sistema de ranking de usuarios.
Este planteamiento justifica la adopción de un enfoque experimental progresivo, basado en la comparación sistemática de modelos, técnicas de balanceo y arquitecturas de ensamble, junto con ciclos iterativos de optimización y reingeniería de variables. El objetivo final es construir un modelo robusto, interpretable y operativamente útil, capaz de generalizar correctamente y de aportar valor en distintos escenarios de decisión.
Fase I - Screening inicial de modelos y estrategias
La selección del modelo se llevó a cabo mediante un proceso de screening progresivo en cuatro fases, orientado a identificar la combinación más eficaz de modelos, técnicas de sampling y esquemas de ensamblaje para un problema altamente desbalanceado. En una primera aproximación se evaluaron modelos base representativos sobre el dataset genérico y su versión con PCA, lo que permitió descartar alternativas poco competitivas y acotar el espacio de búsqueda.
A continuación, se refinaron las configuraciones seleccionadas explorando distintas estrategias de balanceo, técnicas de ensamblaje y arquitecturas de stacking, culminando en la elección de un meta-estimador óptimo. Todas las configuraciones se evaluaron mediante validación cruzada y un conjunto de prueba independiente, garantizando la robustez y generalización de los resultados.
Selección de modelos base
Esta fase corresponde al screening inicial de modelos base y estrategias de balanceo, cuyo objetivo es identificar combinaciones competitivas para un problema de clasificación con fuerte desbalance de clases. La selección de algoritmos se apoya en la literatura reciente sobre predicción de intención de compra y aprendizaje con clases minoritarias, pero sin restringirse a un único paradigma, con el fin de evitar sesgos metodológicos prematuros.
Selección de modelos base
-
Modelos lineales
(
LogisticRegression,RidgeClassifier,SGDClassifier,PassiveAggressiveClassifier,LinearSVC): se incluyen como línea base por su interpretabilidad, eficiencia computacional y buen comportamiento en escenarios de alta dimensionalidad. Además, permiten evaluar el impacto de regularización y márgenes lineales en datos conductuales (García & Rodríguez, 2024; Yasnig, 2025). -
Modelos basados en árboles y ensembles clásicos
(
DecisionTree,RandomForest,GradientBoosting): capturan relaciones no lineales y efectos de interacción entre variables de comportamiento, habituales en contextos de comercio electrónico (Liu et al., 2024). -
Frameworks de gradient boosting de alto rendimiento
(
XGBoost,LightGBM,CatBoost): se incorporan por su capacidad para modelar patrones complejos, manejar grandes volúmenes de datos y ofrecer un excelente equilibrio entre rendimiento y robustez. En particular, CatBoost resulta adecuado para variables categóricas, mientras que LightGBM destaca por su eficiencia computacional (Chen & Guestrin, 2016; Prokhorenkova et al., 2018; Singh et al., 2024). -
Ensembles balanceados de imblearn
(
BalancedRandomForest,EasyEnsemble,RUSBoost,BalancedBagging): diseñados específicamente para escenarios con clases altamente desbalanceadas, combinan muestreo interno y ensamblaje para mejorar la sensibilidad sobre la clase minoritaria (Liu et al., 2024).
Estrategias de balanceo (samplers)
-
Sin muestreo (
passthrough): se incluye como referencia para evaluar la capacidad de los modelos de manejar el desbalance mediante pesos internos o aprendizaje implícito. -
Undersampling
(
TomekLinks,NearMiss): orientado a limpiar solapamientos entre clases y reducir el sesgo hacia la clase mayoritaria, aunque con riesgo de pérdida de información (Ortiz-Clavijo, 2024). -
Oversampling
(
RandomOverSampler,SMOTE,BorderlineSMOTE,ADASYN): técnicas diseñadas para reforzar la representación de la clase minoritaria mediante generación sintética de ejemplos, con variantes adaptativas que priorizan regiones cercanas al límite de decisión (Kim et al., 2024; Liu et al., 2024). -
Estrategias combinadas
(
SMOTEENN,SMOTETomek): integran oversampling y limpieza posterior, buscando un equilibrio entre cobertura de la clase minoritaria y reducción de ruido (Pandiyarajan et al., 2025).
Estrategia de evaluación
Cada combinación de modelo y sampler se evaluó mediante validación cruzada estratificada, priorizando métricas alineadas con el objetivo de negocio, como PR-AUC, Recall y Lift@10%, complementadas con métricas clásicas de clasificación. Este enfoque permite filtrar de forma sistemática las configuraciones más prometedoras, que posteriormente se someten a fases de optimización más costosas.
Resumen conceptual
Esta fase actúa como un filtro exploratorio amplio, diseñado para reducir el espacio de búsqueda sin asumir a priori un modelo dominante. El resultado no es la selección definitiva, sino la identificación de familias de modelos y estrategias de balanceo con potencial real, que justifican una inversión posterior en optimización y análisis en profundidad.
# ============================================================================
# Librerias
# ============================================================================
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import os
import joblib
from time import time
# Scikit-Learn & Imbalanced-Learn
from sklearn.model_selection import StratifiedKFold, cross_val_predict, train_test_split
from sklearn.metrics import precision_recall_curve, auc, roc_auc_score, f1_score, recall_score, confusion_matrix, classification_report
from sklearn.preprocessing import RobustScaler, StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier, StackingClassifier
from sklearn.pipeline import Pipeline as SklearnPipeline
from sklearn.svm import LinearSVC
from sklearn.calibration import CalibratedClassifierCV
from sklearn.linear_model import RidgeClassifier
from sklearn.calibration import CalibratedClassifierCV
from sklearn.linear_model import SGDClassifier
from sklearn.linear_model import LogisticRegressionCV
from sklearn.linear_model import PassiveAggressiveClassifier
from sklearn.tree import DecisionTreeClassifier
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.under_sampling import TomekLinks, RandomUnderSampler
from imblearn.over_sampling import SMOTE, RandomOverSampler, ADASYN
from imblearn.combine import SMOTEENN, SMOTETomek
from imblearn.ensemble import BalancedRandomForestClassifier, EasyEnsembleClassifier, RUSBoostClassifier, BalancedBaggingClassifier
from imblearn.over_sampling import BorderlineSMOTE
from imblearn.under_sampling import NearMiss
# Boosting Libraries
from xgboost import XGBClassifier
from catboost import CatBoostClassifier
from lightgbm import LGBMClassifier
# Configuración de warnings
import warnings
warnings.filterwarnings('ignore')
Definicion de modelos
# ============================================================================
# Definicion de modelos
# ============================================================================
def get_model_configs(random_state=42):
# Modelos lineales
lr = LogisticRegression(solver='liblinear', random_state=random_state)
svc = CalibratedClassifierCV(LinearSVC(class_weight=None, C=0.1, max_iter=10000,random_state=42,dual=False),
method='sigmoid',cv=3)
ridg = CalibratedClassifierCV(RidgeClassifier(
alpha=1.0,class_weight=None,random_state=random_state), cv=3,method='sigmoid')
sgdc = SGDClassifier(loss='log_loss',class_weight=None,alpha=0.0001,max_iter=1000,random_state=random_state,
learning_rate='optimal')
passAgg = CalibratedClassifierCV(
PassiveAggressiveClassifier(class_weight=None,C=0.1,max_iter=1000,random_state=random_state,early_stopping=True),
method='sigmoid',
cv=5
)
lr_cv = LogisticRegressionCV(
class_weight=None,
cv=3,
scoring='average_precision', # Usar PR-AUC para desbalanceo
solver='liblinear',
max_iter=1000,
random_state=42,
n_jobs=-1
)
# Modelos Base
rf = RandomForestClassifier(n_jobs=-1, random_state=random_state)
xgb_model = XGBClassifier(n_jobs=-1, random_state=random_state, eval_metric='logloss')
lgbm = LGBMClassifier(n_jobs=-1, random_state=random_state, verbosity=-1)
cat = CatBoostClassifier(verbose=0, random_state=random_state, allow_writing_files=False)
gb = GradientBoostingClassifier(random_state=random_state)
# Modelos Internal Balancing
lr_int = LogisticRegression(solver='liblinear', class_weight='balanced', random_state=random_state)
lr_cv_int = LogisticRegressionCV(
class_weight='balanced',
cv=3,
scoring='average_precision', # Usar PR-AUC para desbalanceo
solver='liblinear',
max_iter=1000,
random_state=42,
n_jobs=-1
)
svc_int = CalibratedClassifierCV(LinearSVC(class_weight='balanced', C=0.1, max_iter=10000,random_state=42,dual=False),
method='sigmoid',cv=3)
ridg_int = CalibratedClassifierCV(RidgeClassifier(
alpha=1.0,class_weight='balanced',random_state=random_state), cv=3,method='sigmoid')
sgdc_int = SGDClassifier(loss='log_loss',class_weight='balanced',alpha=0.0001,max_iter=1000,random_state=random_state,
learning_rate='optimal')
passAgg_int = CalibratedClassifierCV(
PassiveAggressiveClassifier(class_weight='balanced',C=0.1,max_iter=1000,random_state=random_state, early_stopping=True),
method='sigmoid',
cv=5
)
rf_int = RandomForestClassifier(n_jobs=-1, class_weight='balanced', random_state=random_state)
xgb_int = XGBClassifier(n_jobs=-1, scale_pos_weight=95, random_state=random_state, eval_metric='logloss')
lgbm_int = LGBMClassifier(n_jobs=-1, class_weight='balanced', random_state=random_state, verbosity=-1)
cat_int = CatBoostClassifier(verbose=0, auto_class_weights='Balanced', random_state=random_state, allow_writing_files=False)
# Imblearn Ensembles
brf = BalancedRandomForestClassifier(n_jobs=-1, random_state=random_state)
easy = EasyEnsembleClassifier(n_jobs=-1, random_state=random_state)
rusb = RUSBoostClassifier(estimator=DecisionTreeClassifier(max_depth=3), n_estimators=50, learning_rate=1.0,
sampling_strategy='auto', random_state=42
)
bal_bag = BalancedBaggingClassifier( estimator=DecisionTreeClassifier(), n_estimators=10,
sampling_strategy=0.1,
replacement=False,
random_state=42
)
models = {
'LogisticRegression_linear': lr,
'LogisticRegression_Internal_linear': lr_int,
'RandomForest': rf,
'RandomForest_Internal': rf_int,
'XGBoost': xgb_model,
'XGBoost_Internal': xgb_int,
'LightGBM': lgbm,
'LightGBM_Internal': lgbm_int,
'CatBoost': cat,
'CatBoost_Internal': cat_int,
'GradientBoosting': gb,
'BalancedRandomForest': brf,
'EasyEnsemble': easy,
'Balanced_Bagging': bal_bag,
'RusBoost': rusb,
'SVC_linear': svc,
'SVC_Internal_linear': svc_int,
'Ridge_linear': ridg,
'Ridge_Internal_linear': ridg_int,
'SGDC_linear': sgdc,
'SGDC_Internal_linear': sgdc_int,
'PassiveAggressive_linear': passAgg,
'PassiveAggressive_Internal_linear': passAgg_int,
'LogisticRegressionCV_linear': lr_cv,
'LogisticRegressionCV_Internal_linear': lr_cv_int
}
return models
Definicion de samplers
# ============================================================================
# Definicion de samplers
# ============================================================================
def get_sampler_configs(random_state=42):
return {
# Sin samplers
'Passthrough': 'passthrough',
# Under Sampling
'Under_TomekLinks': TomekLinks(sampling_strategy='auto'),
'Under_NearMiss': NearMiss(version=1, sampling_strategy=0.1, n_neighbors=3),
# Over Sampling
'Over_SMOTE': SMOTE(sampling_strategy=0.1, random_state=random_state),
'Over_BORDER_SMOTE': BorderlineSMOTE(sampling_strategy=0.3, k_neighbors=5, m_neighbors=10, kind='borderline-1', random_state=random_state),
'Over_ROS': RandomOverSampler(sampling_strategy=0.1, random_state=random_state),
# Combinado
'SMOTE_ENN': SMOTEENN(sampling_strategy=0.1, random_state=random_state),
'SMOTE_Tomek': SMOTETomek(sampling_strategy=0.1, random_state=random_state),
}
Visualizacion y metricas
# ============================================================================
# Funcionalidades de Visualizacion e informacion adicional
# ============================================================================
def calculate_business_metrics(y_true, y_prob, top_k=0.1):
"""
Calcula el Lift y la Ganancia en el top K% de la población (decil superior).
"""
df_res = pd.DataFrame({'y_true': y_true, 'y_prob': y_prob})
df_res = df_res.sort_values('y_prob', ascending=False)
# Seleccionar top k%
top_n = int(len(df_res) * top_k)
df_top = df_res.head(top_n)
# Tasa base de respuesta
base_rate = np.mean(y_true)
# Tasa de respuesta en el grupo seleccionado
target_rate = np.mean(df_top['y_true'])
# Lift
lift = target_rate / base_rate if base_rate > 0 else 0
# Gain (Recall acumulado al top K)
total_positives = np.sum(y_true)
captured_positives = np.sum(df_top['y_true'])
gain = captured_positives / total_positives if total_positives > 0 else 0
return lift, gain
def get_gain_lift_coordinates(y_true, y_prob):
"""
Calcula las coordenadas para las curvas de Gain y Lift manualmente
para no depender de librerías externas como scikit-plot.
"""
# Crear dataframe temporal y ordenar por probabilidad descendente
data = pd.DataFrame({'y_true': y_true, 'y_prob': y_prob})
data = data.sort_values(by='y_prob', ascending=False).reset_index(drop=True)
# Calcular positivos acumulados y total de positivos
data['cum_positives'] = data['y_true'].cumsum()
total_positives = data['y_true'].sum()
total_obs = len(data)
# Eje X: Porcentaje de la población (de 0 a 1)
percentages = np.arange(1, total_obs + 1) / total_obs
# Eje Y (Gain): Porcentaje de positivos capturados
gains = data['cum_positives'] / total_positives
# Eje Y (Lift): Gain / Porcentaje de población
# Lift = (Positivos Capturados / Total Positivos) / (Población Contactada / Total Población)
# Nota: Matemáticamente equivale a: Tasa Respuesta Grupo / Tasa Respuesta Global
lifts = gains / percentages
return percentages, gains, lifts
def plot_business_curves(results_dict):
"""
Grafica las curvas de Gain y Lift superponiendo los Top 3 modelos.
results_dict: Diccionario { 'Nombre Modelo': (y_true, y_prob) }
"""
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# --- GRÁFICO 1: Cumulative Gain Curve ---
ax_gain = axes[0]
ax_gain.plot([0, 1], [0, 1], 'k--', label='Baseline (Random)') # Línea base
# --- GRÁFICO 2: Lift Curve ---
ax_lift = axes[1]
ax_lift.plot([0, 1], [1, 1], 'k--', label='Baseline (Random)') # Línea base
for label, (y_true, y_prob) in results_dict.items():
pcts, gains, lifts = get_gain_lift_coordinates(y_true, y_prob)
# Plot Gain
ax_gain.plot(pcts, gains, lw=2, label=label)
# Plot Lift (Recortamos el primer 1% si es muy ruidoso o infinito, opcional)
ax_lift.plot(pcts, lifts, lw=2, label=label)
# Configuración Gain
ax_gain.set_title('Curva de Ganancia Acumulada (Cumulative Gain)', fontsize=14)
ax_gain.set_xlabel('% Población Contactada (ordenada por score)', fontsize=12)
ax_gain.set_ylabel('% de Compradores (Positivos) Capturados', fontsize=12)
ax_gain.legend()
ax_gain.grid(True, alpha=0.3)
# Configuración Lift
ax_lift.set_title('Curva de Lift', fontsize=14)
ax_lift.set_xlabel('% Población Contactada', fontsize=12)
ax_lift.set_ylabel('Lift (Veces mejor que el azar)', fontsize=12)
ax_lift.legend()
ax_lift.grid(True, alpha=0.3)
# Limitar eje Y del Lift para evitar distorsión por picos extremos al inicio
# Ajusta este límite según tus datos, normalmente un lift > 10 es raro en 1% dataset
ax_lift.set_ylim(0, ax_lift.get_ylim()[1])
plt.tight_layout()
plt.show()
def analizar_top_5(df_full, target_col, output_folder='results/samplers_fase1', test_size=0.1, random_state=42):
"""
Carga resultados, selecciona Top 3, entrena en Train/Validation Set y
genera métricas finales en el Test Set (Hold-out).
"""
df_results = cargar_resultados(output_folder)
if df_results is None: return
top_5 = df_results.head(5)
# 1. RECREAR LOS CONJUNTOS DE DATOS (Train/Validation y Test)
X_full = df_full.drop(columns=[target_col])
y_full = df_full[target_col].astype(int)
if test_size > 0:
# Se recrea el split EXACTO usado en la fase de screening
X_train_val, X_test, y_train_val, y_test = train_test_split(
X_full, y_full, test_size=test_size, stratify=y_full, random_state=random_state
)
# El conjunto de entrenamiento final es el Train/Validation
X_FINAL_TRAIN = X_train_val.astype(float)
y_FINAL_TRAIN = y_train_val
# El conjunto de prueba final es el Test (Hold-out)
X_FINAL_TEST = X_test.astype(float)
y_FINAL_TEST = y_test
evaluation_set_name = "Conjunto de Test (Hold-out)"
else:
# Si test_size es 0, evaluamos en el mismo conjunto de entrenamiento (advertir sobre sesgo)
X_FINAL_TRAIN = X_full.astype(float)
y_FINAL_TRAIN = y_full
X_FINAL_TEST = X_full.astype(float)
y_FINAL_TEST = y_full
evaluation_set_name = "Conjunto de Entrenamiento (¡Métricas sesgadas!)"
numeric_features = X_FINAL_TRAIN.columns.tolist()
models_config = get_model_configs()
samplers_config = get_sampler_configs()
print("\n" + "="*80)
print(f" ANÁLISIS DETALLADO TOP 3 MODELOS - EVALUACIÓN FINAL EN {evaluation_set_name}")
print("="*80)
predictions_storage = {}
for index, row in top_5.iterrows():
m_name = row['Model']
s_name = row['Sampler']
full_name = f"#{index+1} {m_name} ({s_name})"
print(f"\n> Evaluando: {full_name}")
# --- 2. Reconstrucción y Entrenamiento Final del Pipeline ---
model = models_config[m_name]
sampler = samplers_config[s_name] if s_name != 'passthrough' else 'passthrough'
requires_scaling = 'linear' in m_name
steps = []
if sampler != 'passthrough': steps.append(('sampler', sampler))
if requires_scaling:
scaler_transformer = ColumnTransformer(
transformers=[('num', RobustScaler(), numeric_features)],
remainder='passthrough'
)
steps.append(('scaler', scaler_transformer))
steps.append(('classifier', model))
pipeline = ImbPipeline(steps=steps)
# ENTRENAMIENTO FINAL con X_train_val
start_t = time()
pipeline.fit(X_FINAL_TRAIN, y_FINAL_TRAIN)
print(f" Tiempo de entrenamiento final: {time() - start_t:.2f} s")
# --- 3. Predicción en el Conjunto de Test ---
y_pred_proba_test = pipeline.predict_proba(X_FINAL_TEST)[:, 1]
y_test_true = y_FINAL_TEST
# Guardamos para las curvas comparativas
predictions_storage[full_name] = (y_test_true, y_pred_proba_test)
# --- 4. Cálculo de Métricas Finales ---
precision, recall, _ = precision_recall_curve(y_test_true, y_pred_proba_test)
pr_auc = auc(recall, precision)
roc_auc = roc_auc_score(y_test_true, y_pred_proba_test)
y_pred_class = (y_pred_proba_test >= 0.5).astype(int)
lift_10, gain_10 = calculate_business_metrics(y_test_true, y_pred_proba_test, top_k=0.1)
print("\n **Métricas Finales (Conjunto de Test):**")
print(f" PR-AUC: {pr_auc:.4f} (Métrica de Ref.)")
print(f" ROC-AUC: {roc_auc:.4f}")
print(f" Lift@10%: {lift_10:.2f}")
print(f" Gain@10%: {gain_10:.4f}")
print("\n Reporte de Clasificación (Threshold 0.5):")
print(classification_report(y_test_true, y_pred_class))
# --- 5. Matriz de Confusión Individual ---
cm = confusion_matrix(y_test_true, y_pred_class)
plt.figure(figsize=(5, 3))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', cbar=False)
plt.title(f'Conf. Matrix (TEST SET): {m_name}\nSampler: {s_name}')
plt.xlabel('Predicho')
plt.ylabel('Real')
plt.show()
print("-" * 60)
# --- 6. Generar Curvas de Negocio Comparativas ---
print("\nGenerando Curvas de Negocio Comparativas (Gain & Lift) en el Conjunto de Test...")
plot_business_curves(predictions_storage)
print("\nAnálisis completado.")
def visualizar_resultados_completos(file_path, metricas_clave=['PR_AUC', 'ROC_AUC', 'Lift_Top10']):
"""
Carga el archivo CSV de resultados, ordena por la métrica principal (PR-AUC)
y aplica formato condicional para una visualización rápida.
Parámetros:
- file_path (str): Ruta completa al archivo CSV de resultados (e.g., 'results/samplers_fase1/summary_results.csv').
- metricas_clave (list): Lista de métricas a resaltar con mapa de calor.
"""
try:
df = pd.read_csv(file_path)
except FileNotFoundError:
print(f"Error: Archivo no encontrado en la ruta: {file_path}")
return
# 1. Limpieza y Ordenación
# Rellenar valores nulos (errores de ejecución) con 0 para la ordenación
df = df.fillna(0)
# Ordenar por la métrica de referencia (PR-AUC)
df_sorted = df.sort_values(by='PR_AUC', ascending=False).reset_index(drop=True)
# Redondear valores para la presentación
df_styled = df_sorted.round({
'PR_AUC': 4,
'ROC_AUC': 4,
'F1_Score': 4,
'Recall': 4,
'Lift_Top10': 2,
'Gain_Top10': 4,
'Time_Sec': 2
})
# 2. Aplicación de Estilos y Mapa de Calor
# Crear el objeto Styler para aplicar formato
styler = df_styled.style
# Resaltar la fila con el mejor PR-AUC (la primera fila)
styler = styler.apply(
lambda x: ['background-color: lightgreen' if x.name == 0 else '' for i in x],
axis=1
)
# Aplicar Mapa de Calor a las métricas clave
for col in metricas_clave:
if col in df_styled.columns:
styler = styler.background_gradient(
cmap='Blues', # Color más oscuro = mejor rendimiento
subset=[col]
)
# Formato para el tiempo
styler = styler.background_gradient(
cmap='Reds_r', # Color más claro = mejor rendimiento (menos tiempo)
subset=['Time_Sec']
)
# Mostrar el título
display(HTML(f'<strong>🏆 Resumen Completo del Screening de Modelos y Samplers (Total: {len(df_sorted)} combinaciones)</strong>'))
# Mostrar la tabla formateada
display(styler)
# Nota: Para que la tabla se muestre correctamente con colores y formato,
# debes ejecutar esta función en un entorno de Jupyter Notebook o Google Colab.
Funcion maestra de ejecucion
# ============================================================================
# Funcionalidad maestra de ejecucion
# ============================================================================
def ejecutar_experimento_screening(df, target_col, output_folder='results/samplers_fase1', cv= 3, test_size=0.1, is_PCA=False, random_state=42):
"""
Ejecuta el screening de modelos sobre el conjunto Train/Validation,
separando previamente un conjunto de Test (Hold-out).
"""
if not os.path.exists(output_folder):
os.makedirs(output_folder)
# --- PREPARACIÓN DEL SPLIT Y TIPOS ---
X_full = df.drop(columns=[target_col])
y_full = df[target_col].astype(int) # La target debe ser int para Stratified Split
# 1. Separación del conjunto de Test (Hold-out)
if test_size > 0:
print(f"Separando el {test_size*100}% del dataset para el conjunto de Test final.")
X_train_val, X_test, y_train_val, y_test = train_test_split(
X_full, y_full, test_size=test_size, stratify=y_full, random_state=random_state
)
else:
# Si no se pide split, todo es Train/Validation
X_train_val, y_train_val = X_full, y_full
# 2. Asignación del conjunto de trabajo
X = X_train_val
y = y_train_val
# 3. Corrección de Tipos (Mantenemos la corrección del paso anterior)
X = X.astype(float)
print("Preparación de datos completada.")
# ------------------------------------
numeric_features = X.columns.tolist()
models = get_model_configs(random_state)
samplers = get_sampler_configs(random_state)
results_list = []
# Usamos 3 folds para ser consistentes, pero si la data es muy grande, 5 o 10 es mejor
n_splits_cv = cv
print(f"Iniciando Screening: {len(models)} Modelos x {len(samplers)} Samplers. Usando CV con {n_splits_cv} folds.")
if is_PCA:
tit("PCA dataset")
for model_name, model in models.items():
# Definir si el modelo requiere escalado
requires_scaling = False
if(not is_PCA):
requires_scaling = 'linear' in model_name
#Marcador para no samplear modelos con balanceo interno
internal_balancing = 'Internal' in model_name
for sampler_name, sampler in samplers.items():
# Descartamos entrenar modelos con balanceo interno con samples distintos de passthrough (No sample)
if internal_balancing and sampler_name != 'Passthrough':
print(f"⏭️ Saltando {model_name} + {sampler_name} "
f"(modelo interno, solo passthrough)")
continue
start_time = time()
print(f" -> Testing: {model_name} + {sampler_name}")
try:
steps = []
if sampler != 'passthrough':
steps.append(('sampler', sampler))
if requires_scaling:
scaler_transformer = ColumnTransformer(
transformers=[('num', RobustScaler(), numeric_features)],
remainder='passthrough'
)
steps.append(('scaler', scaler_transformer))
steps.append(('classifier', model))
pipeline = ImbPipeline(steps=steps)
cv = StratifiedKFold(n_splits=n_splits_cv, shuffle=True, random_state=random_state)
y_pred_proba_cv = cross_val_predict(pipeline, X, y, cv=cv, method='predict_proba', n_jobs=-1)[:, 1]
# Calcular Métricas
precision, recall, _ = precision_recall_curve(y, y_pred_proba_cv)
pr_auc = auc(recall, precision)
roc_auc = roc_auc_score(y, y_pred_proba_cv)
y_pred_class = (y_pred_proba_cv >= 0.5).astype(int)
f1 = f1_score(y, y_pred_class)
rec = recall_score(y, y_pred_class)
lift_10, gain_10 = calculate_business_metrics(y, y_pred_proba_cv, top_k=0.1)
elapsed = time() - start_time
results_list.append({
'Model': model_name,
'Sampler': sampler_name,
'PR_AUC': pr_auc,
'ROC_AUC': roc_auc,
'F1_Score': f1,
'Recall': rec,
'Lift_Top10': lift_10,
'Gain_Top10': gain_10,
'Time_Sec': elapsed
})
except Exception as e:
print(f" !!! Error en {model_name} + {sampler_name}: {str(e)}")
df_results = pd.DataFrame(results_list)
if not df_results.empty:
df_results = df_results.sort_values(by='PR_AUC', ascending=False)
file_path = os.path.join(output_folder, 'summary_results.csv')
df_results.to_csv(file_path, index=False)
print(f"\nResumen guardado en: {file_path}")
return df_results
else:
print("\nNo se generaron resultados debido a errores.")
return None
def cargar_resultados(output_folder='results/samplers_fase1'):
"""Carga los resultados cacheados."""
file_path = os.path.join(output_folder, 'summary_results.csv')
if os.path.exists(file_path):
return pd.read_csv(file_path)
else:
print("No se encontraron resultados previos.")
return None
Ejecucion de experimento - Dataset Genérico
file_path_results = 'results/samplers_fase1/summary_results.csv'
file_path = 'results/samplers_fase1'
if os.path.exists(file_path_results):
print(f"Cargando screening desde {file_path_results}...")
df_results = pd.read_csv(file_path_results)
else:
df_results = ejecutar_experimento_screening(df_preproc, 'es_cliente', file_path, 5, test_size=0.1)
Cargando screening desde results/samplers_fase1/summary_results.csv...
visualizar_resultados_completos(file_path_results, metricas_clave=['F1_Score', 'Recall', 'Lift_Top10'])
| Model | Sampler | PR_AUC | ROC_AUC | F1_Score | Recall | Lift_Top10 | Gain_Top10 | Time_Sec | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | CatBoost | Passthrough | 0.439300 | 0.909600 | 0.435800 | 0.290000 | 7.270000 | 0.726800 | 37.140000 |
| 1 | RusBoost | Under_NearMiss | 0.438200 | 0.652100 | 0.030000 | 0.880700 | 1.600000 | 0.160100 | 5.000000 |
| 2 | CatBoost | Under_TomekLinks | 0.436600 | 0.909500 | 0.434700 | 0.290000 | 7.270000 | 0.726800 | 144.420000 |
| 3 | LightGBM | Over_ROS | 0.421500 | 0.911000 | 0.395700 | 0.426100 | 7.350000 | 0.734600 | 2.380000 |
| 4 | BalancedRandomForest | Under_NearMiss | 0.421300 | 0.657600 | 0.021900 | 0.952400 | 1.720000 | 0.171900 | 2.410000 |
| 5 | RandomForest | Under_NearMiss | 0.418200 | 0.651900 | 0.021700 | 0.940600 | 1.690000 | 0.169100 | 2.560000 |
| 6 | XGBoost | Passthrough | 0.416100 | 0.901100 | 0.424100 | 0.287800 | 7.150000 | 0.715000 | 2.050000 |
| 7 | XGBoost | Under_TomekLinks | 0.415300 | 0.901400 | 0.424400 | 0.287800 | 7.170000 | 0.716700 | 108.690000 |
| 8 | CatBoost | SMOTE_ENN | 0.414200 | 0.905700 | 0.419400 | 0.310200 | 7.350000 | 0.734600 | 171.350000 |
| 9 | LightGBM | Passthrough | 0.413700 | 0.909400 | 0.408100 | 0.275500 | 7.280000 | 0.727900 | 2.610000 |
| 10 | CatBoost | Over_SMOTE | 0.413600 | 0.879200 | 0.432600 | 0.299000 | 6.320000 | 0.632100 | 45.660000 |
| 11 | LightGBM | SMOTE_ENN | 0.412100 | 0.912300 | 0.412500 | 0.295600 | 7.410000 | 0.740800 | 129.600000 |
| 12 | LightGBM | Under_TomekLinks | 0.411300 | 0.909000 | 0.412900 | 0.280500 | 7.350000 | 0.735200 | 110.370000 |
| 13 | CatBoost | SMOTE_Tomek | 0.410900 | 0.879000 | 0.431000 | 0.299000 | 6.300000 | 0.630500 | 169.800000 |
| 14 | XGBoost | SMOTE_ENN | 0.409000 | 0.903900 | 0.414600 | 0.320300 | 7.300000 | 0.730100 | 129.420000 |
| 15 | LightGBM | SMOTE_Tomek | 0.407500 | 0.897400 | 0.403500 | 0.269300 | 7.160000 | 0.715600 | 126.810000 |
| 16 | XGBoost | Over_SMOTE | 0.406400 | 0.878200 | 0.423600 | 0.294000 | 6.420000 | 0.641700 | 2.390000 |
| 17 | XGBoost | SMOTE_Tomek | 0.404300 | 0.878200 | 0.425100 | 0.295600 | 6.460000 | 0.646100 | 129.880000 |
| 18 | CatBoost | Over_ROS | 0.404100 | 0.878100 | 0.401800 | 0.397500 | 6.660000 | 0.666300 | 41.470000 |
| 19 | XGBoost | Over_ROS | 0.403700 | 0.885500 | 0.391400 | 0.412100 | 6.850000 | 0.685300 | 2.050000 |
| 20 | LightGBM | Over_SMOTE | 0.403600 | 0.896200 | 0.396800 | 0.266000 | 7.040000 | 0.704400 | 2.610000 |
| 21 | LightGBM_Internal | Passthrough | 0.389100 | 0.901500 | 0.112000 | 0.739600 | 7.100000 | 0.710000 | 2.250000 |
| 22 | XGBoost_Internal | Passthrough | 0.371300 | 0.856700 | 0.128400 | 0.618100 | 6.380000 | 0.637700 | 1.930000 |
| 23 | CatBoost_Internal | Passthrough | 0.370100 | 0.854600 | 0.135600 | 0.609200 | 6.360000 | 0.636100 | 40.900000 |
| 24 | CatBoost | Over_BORDER_SMOTE | 0.370000 | 0.892300 | 0.428100 | 0.317500 | 6.730000 | 0.673000 | 53.270000 |
| 25 | GradientBoosting | Under_TomekLinks | 0.365600 | 0.901700 | 0.295500 | 0.175800 | 7.110000 | 0.710500 | 120.910000 |
| 26 | GradientBoosting | Passthrough | 0.363100 | 0.901600 | 0.287700 | 0.170200 | 7.090000 | 0.709400 | 11.540000 |
| 27 | RandomForest | Passthrough | 0.362900 | 0.823100 | 0.345800 | 0.225600 | 6.640000 | 0.663500 | 8.610000 |
| 28 | RandomForest | Under_TomekLinks | 0.362300 | 0.828300 | 0.352900 | 0.232400 | 6.640000 | 0.663500 | 116.480000 |
| 29 | GradientBoosting | Over_ROS | 0.357000 | 0.908000 | 0.349300 | 0.374600 | 7.230000 | 0.722800 | 12.730000 |
| 30 | XGBoost | Over_BORDER_SMOTE | 0.351300 | 0.891700 | 0.403400 | 0.309600 | 6.540000 | 0.654000 | 4.140000 |
| 31 | LightGBM | Over_BORDER_SMOTE | 0.343100 | 0.900000 | 0.381100 | 0.298400 | 6.920000 | 0.692000 | 4.230000 |
| 32 | RandomForest | SMOTE_Tomek | 0.340800 | 0.822500 | 0.362800 | 0.257000 | 6.160000 | 0.616500 | 136.380000 |
| 33 | RandomForest | Over_SMOTE | 0.340700 | 0.817700 | 0.370800 | 0.262000 | 6.300000 | 0.630500 | 10.020000 |
| 34 | Balanced_Bagging | Passthrough | 0.336200 | 0.847600 | 0.282100 | 0.457400 | 6.850000 | 0.684800 | 6.400000 |
| 35 | Balanced_Bagging | Under_TomekLinks | 0.335400 | 0.845800 | 0.275500 | 0.455800 | 6.750000 | 0.674700 | 109.950000 |
| 36 | GradientBoosting | Over_SMOTE | 0.332700 | 0.898600 | 0.314200 | 0.204400 | 7.130000 | 0.713300 | 14.650000 |
| 37 | GradientBoosting | SMOTE_Tomek | 0.330800 | 0.898900 | 0.308000 | 0.198800 | 7.110000 | 0.711100 | 136.160000 |
| 38 | GradientBoosting | SMOTE_ENN | 0.330400 | 0.896900 | 0.336700 | 0.258700 | 7.230000 | 0.722800 | 140.000000 |
| 39 | BalancedRandomForest | Under_TomekLinks | 0.319200 | 0.900300 | 0.105300 | 0.770400 | 7.180000 | 0.717800 | 106.150000 |
| 40 | Balanced_Bagging | SMOTE_ENN | 0.319000 | 0.768400 | 0.325600 | 0.375700 | 5.910000 | 0.591300 | 133.380000 |
| 41 | BalancedRandomForest | Passthrough | 0.312200 | 0.899300 | 0.104500 | 0.763700 | 7.180000 | 0.717800 | 3.160000 |
| 42 | BalancedRandomForest | Over_SMOTE | 0.293100 | 0.866400 | 0.238000 | 0.465300 | 6.410000 | 0.641100 | 4.890000 |
| 43 | BalancedRandomForest | SMOTE_Tomek | 0.293000 | 0.866900 | 0.230500 | 0.465800 | 6.420000 | 0.641700 | 130.820000 |
| 44 | GradientBoosting | Over_BORDER_SMOTE | 0.282700 | 0.897100 | 0.264900 | 0.394700 | 7.000000 | 0.699900 | 24.440000 |
| 45 | RandomForest | Over_BORDER_SMOTE | 0.280700 | 0.829900 | 0.330500 | 0.249700 | 6.800000 | 0.679700 | 13.080000 |
| 46 | BalancedRandomForest | SMOTE_ENN | 0.268100 | 0.863700 | 0.258100 | 0.528600 | 7.220000 | 0.721700 | 130.550000 |
| 47 | RandomForest | SMOTE_ENN | 0.262300 | 0.816600 | 0.311000 | 0.306800 | 6.890000 | 0.688700 | 135.800000 |
| 48 | RusBoost | Under_TomekLinks | 0.248800 | 0.815800 | 0.080200 | 0.695400 | 5.390000 | 0.539200 | 109.610000 |
| 49 | RandomForest | Over_ROS | 0.243500 | 0.812500 | 0.295000 | 0.285600 | 6.230000 | 0.623200 | 9.520000 |
| 50 | BalancedRandomForest | Over_BORDER_SMOTE | 0.242200 | 0.858500 | 0.254000 | 0.323600 | 7.020000 | 0.701600 | 11.860000 |
| 51 | EasyEnsemble | SMOTE_ENN | 0.239200 | 0.881100 | 0.097700 | 0.726200 | 6.560000 | 0.656200 | 133.360000 |
| 52 | SVC_linear | Passthrough | 0.239200 | 0.851700 | 0.158600 | 0.087900 | 6.270000 | 0.627100 | 2.010000 |
| 53 | SVC_linear | Under_TomekLinks | 0.237800 | 0.852200 | 0.158500 | 0.087900 | 6.280000 | 0.627700 | 106.910000 |
| 54 | LogisticRegression_linear | SMOTE_Tomek | 0.236100 | 0.871200 | 0.289700 | 0.321900 | 6.650000 | 0.664600 | 135.840000 |
| 55 | LogisticRegression_linear | Over_SMOTE | 0.235500 | 0.871200 | 0.288800 | 0.321400 | 6.650000 | 0.665200 | 5.080000 |
| 56 | LogisticRegressionCV_linear | SMOTE_Tomek | 0.232700 | 0.872000 | 0.323700 | 0.288400 | 6.710000 | 0.670800 | 138.700000 |
| 57 | LogisticRegressionCV_linear | Over_SMOTE | 0.232400 | 0.872000 | 0.322900 | 0.288400 | 6.710000 | 0.670800 | 13.920000 |
| 58 | SVC_linear | SMOTE_Tomek | 0.228100 | 0.867900 | 0.265800 | 0.308000 | 6.470000 | 0.646700 | 126.210000 |
| 59 | SVC_linear | Over_SMOTE | 0.227800 | 0.867900 | 0.265700 | 0.308500 | 6.470000 | 0.646700 | 2.470000 |
| 60 | LogisticRegression_linear | SMOTE_ENN | 0.226900 | 0.870000 | 0.294700 | 0.352700 | 6.820000 | 0.682000 | 136.480000 |
| 61 | LogisticRegressionCV_linear | Passthrough | 0.224800 | 0.867000 | 0.077800 | 0.040900 | 6.430000 | 0.642800 | 14.030000 |
| 62 | SGDC_linear | Under_TomekLinks | 0.224200 | 0.780800 | 0.172400 | 0.095700 | 5.720000 | 0.572200 | 107.630000 |
| 63 | EasyEnsemble | Over_SMOTE | 0.224000 | 0.875700 | 0.086800 | 0.768200 | 6.420000 | 0.642200 | 9.210000 |
| 64 | LogisticRegressionCV_linear | Under_TomekLinks | 0.224000 | 0.867200 | 0.077800 | 0.040900 | 6.430000 | 0.642800 | 119.960000 |
| 65 | LogisticRegression_linear | Over_ROS | 0.223900 | 0.874100 | 0.289700 | 0.314700 | 6.690000 | 0.668500 | 2.050000 |
| 66 | LogisticRegression_linear | Passthrough | 0.222100 | 0.867600 | 0.093900 | 0.049800 | 6.420000 | 0.641700 | 7.170000 |
| 67 | LogisticRegression_linear | Under_TomekLinks | 0.221700 | 0.867900 | 0.095900 | 0.051000 | 6.460000 | 0.645600 | 146.920000 |
| 68 | LogisticRegressionCV_linear | Over_ROS | 0.220800 | 0.873600 | 0.315400 | 0.280000 | 6.700000 | 0.670200 | 13.790000 |
| 69 | EasyEnsemble | SMOTE_Tomek | 0.220300 | 0.876500 | 0.091300 | 0.762000 | 6.440000 | 0.644500 | 131.390000 |
| 70 | LogisticRegressionCV_linear | SMOTE_ENN | 0.217800 | 0.868000 | 0.323300 | 0.308500 | 6.730000 | 0.673000 | 138.730000 |
| 71 | EasyEnsemble | Passthrough | 0.217500 | 0.878700 | 0.083600 | 0.769900 | 6.440000 | 0.643900 | 3.480000 |
| 72 | EasyEnsemble | Over_ROS | 0.217100 | 0.876000 | 0.084500 | 0.781600 | 6.440000 | 0.643900 | 8.340000 |
| 73 | SVC_linear | Over_ROS | 0.215400 | 0.869000 | 0.271800 | 0.307400 | 6.510000 | 0.650600 | 2.390000 |
| 74 | EasyEnsemble | Under_TomekLinks | 0.214600 | 0.878800 | 0.080300 | 0.787800 | 6.380000 | 0.637700 | 107.390000 |
| 75 | LogisticRegression_linear | Over_BORDER_SMOTE | 0.212700 | 0.874300 | 0.158500 | 0.608600 | 6.720000 | 0.672500 | 3.490000 |
| 76 | RusBoost | Passthrough | 0.212200 | 0.805700 | 0.072000 | 0.562700 | 5.270000 | 0.526900 | 4.340000 |
| 77 | SVC_linear | SMOTE_ENN | 0.211100 | 0.867000 | 0.260900 | 0.330300 | 6.560000 | 0.656200 | 128.610000 |
| 78 | LogisticRegressionCV_linear | Over_BORDER_SMOTE | 0.208500 | 0.873700 | 0.165500 | 0.600200 | 6.750000 | 0.675300 | 16.390000 |
| 79 | SVC_linear | Over_BORDER_SMOTE | 0.193600 | 0.871800 | 0.139600 | 0.607500 | 6.690000 | 0.669100 | 3.650000 |
| 80 | BalancedRandomForest | Over_ROS | 0.193000 | 0.849400 | 0.140200 | 0.512900 | 6.130000 | 0.613100 | 4.480000 |
| 81 | SGDC_Internal_linear | Passthrough | 0.191600 | 0.807500 | 0.051400 | 0.782200 | 5.020000 | 0.502200 | 1.870000 |
| 82 | RandomForest_Internal | Passthrough | 0.189100 | 0.743500 | 0.134800 | 0.258100 | 3.740000 | 0.374000 | 8.220000 |
| 83 | RusBoost | Over_SMOTE | 0.187900 | 0.832200 | 0.087500 | 0.728400 | 5.240000 | 0.524100 | 3.850000 |
| 84 | Ridge_linear | SMOTE_Tomek | 0.183600 | 0.860200 | 0.272200 | 0.317500 | 6.520000 | 0.652300 | 124.730000 |
| 85 | Ridge_linear | Over_SMOTE | 0.183200 | 0.860300 | 0.272700 | 0.317500 | 6.530000 | 0.653400 | 1.100000 |
| 86 | Ridge_linear | Passthrough | 0.176700 | 0.850300 | 0.090100 | 0.050400 | 6.330000 | 0.633300 | 0.940000 |
| 87 | LogisticRegression_Internal_linear | Passthrough | 0.176500 | 0.875100 | 0.085300 | 0.788400 | 6.690000 | 0.669100 | 1.870000 |
| 88 | LogisticRegressionCV_Internal_linear | Passthrough | 0.176500 | 0.875100 | 0.085300 | 0.788400 | 6.690000 | 0.669100 | 16.340000 |
| 89 | LogisticRegressionCV_linear | Under_NearMiss | 0.174100 | 0.657500 | 0.024400 | 0.866700 | 2.270000 | 0.226800 | 2.300000 |
| 90 | Ridge_linear | Under_TomekLinks | 0.174000 | 0.850200 | 0.090700 | 0.051000 | 6.350000 | 0.634900 | 105.790000 |
| 91 | Ridge_linear | Over_ROS | 0.173400 | 0.861200 | 0.268000 | 0.292800 | 6.550000 | 0.655100 | 1.060000 |
| 92 | SVC_Internal_linear | Passthrough | 0.165900 | 0.872600 | 0.053500 | 0.028000 | 6.590000 | 0.659000 | 2.610000 |
| 93 | Ridge_linear | SMOTE_ENN | 0.163900 | 0.855300 | 0.276200 | 0.331500 | 6.550000 | 0.655100 | 125.140000 |
| 94 | RusBoost | Over_ROS | 0.162000 | 0.840300 | 0.080700 | 0.775500 | 5.260000 | 0.526300 | 0.970000 |
| 95 | Ridge_linear | Over_BORDER_SMOTE | 0.158300 | 0.862300 | 0.130600 | 0.566100 | 6.180000 | 0.617600 | 2.220000 |
| 96 | SGDC_linear | Over_SMOTE | 0.143800 | 0.801000 | 0.264700 | 0.295600 | 6.030000 | 0.603000 | 4.810000 |
| 97 | EasyEnsemble | Over_BORDER_SMOTE | 0.140300 | 0.873800 | 0.095900 | 0.719500 | 6.330000 | 0.632700 | 30.700000 |
| 98 | Ridge_Internal_linear | Passthrough | 0.139900 | 0.865700 | 0.026300 | 0.013400 | 5.970000 | 0.597400 | 0.990000 |
| 99 | SGDC_linear | SMOTE_Tomek | 0.134000 | 0.814800 | 0.266500 | 0.307400 | 6.190000 | 0.618700 | 128.070000 |
| 100 | RusBoost | SMOTE_Tomek | 0.133800 | 0.838600 | 0.077800 | 0.703200 | 5.210000 | 0.520700 | 126.480000 |
| 101 | SGDC_linear | Under_NearMiss | 0.132800 | 0.705600 | 0.030900 | 0.847100 | 2.510000 | 0.251400 | 1.880000 |
| 102 | SGDC_linear | Passthrough | 0.127100 | 0.790600 | 0.195800 | 0.137200 | 5.850000 | 0.585100 | 2.550000 |
| 103 | LogisticRegression_linear | Under_NearMiss | 0.122500 | 0.699800 | 0.030000 | 0.850500 | 2.430000 | 0.243000 | 5.440000 |
| 104 | SGDC_linear | SMOTE_ENN | 0.120000 | 0.861600 | 0.195600 | 0.509000 | 6.720000 | 0.672500 | 127.370000 |
| 105 | SGDC_linear | Over_ROS | 0.112100 | 0.831700 | 0.230800 | 0.351600 | 6.200000 | 0.620400 | 4.480000 |
| 106 | SGDC_linear | Over_BORDER_SMOTE | 0.108700 | 0.834700 | 0.129100 | 0.583400 | 6.250000 | 0.624900 | 7.650000 |
| 107 | SVC_linear | Under_NearMiss | 0.106900 | 0.711000 | 0.030700 | 0.841500 | 2.790000 | 0.278800 | 2.150000 |
| 108 | PassiveAggressive_linear | Over_BORDER_SMOTE | 0.106200 | 0.851100 | 0.192000 | 0.190900 | 6.030000 | 0.603000 | 3.640000 |
| 109 | RusBoost | SMOTE_ENN | 0.102800 | 0.841000 | 0.103100 | 0.512300 | 5.170000 | 0.516800 | 129.040000 |
| 110 | PassiveAggressive_linear | Under_TomekLinks | 0.096800 | 0.783800 | 0.000000 | 0.000000 | 4.950000 | 0.495000 | 106.050000 |
| 111 | PassiveAggressive_linear | SMOTE_ENN | 0.089100 | 0.832400 | 0.070800 | 0.043100 | 6.050000 | 0.605300 | 126.160000 |
| 112 | PassiveAggressive_linear | Under_NearMiss | 0.088900 | 0.706800 | 0.027700 | 0.813000 | 2.980000 | 0.297900 | 2.060000 |
| 113 | RusBoost | Over_BORDER_SMOTE | 0.078600 | 0.856500 | 0.102500 | 0.637200 | 5.870000 | 0.587300 | 3.220000 |
| 114 | PassiveAggressive_linear | SMOTE_Tomek | 0.065500 | 0.822200 | 0.040300 | 0.024600 | 4.950000 | 0.495000 | 125.860000 |
| 115 | PassiveAggressive_Internal_linear | Passthrough | 0.064500 | 0.784000 | 0.001100 | 0.000600 | 4.760000 | 0.475900 | 2.400000 |
| 116 | PassiveAggressive_linear | Over_ROS | 0.062500 | 0.801500 | 0.019800 | 0.011200 | 5.410000 | 0.540900 | 2.280000 |
| 117 | PassiveAggressive_linear | Over_SMOTE | 0.057500 | 0.759600 | 0.034300 | 0.019600 | 4.760000 | 0.475900 | 2.210000 |
| 118 | PassiveAggressive_linear | Passthrough | 0.049200 | 0.695400 | 0.000000 | 0.000000 | 4.410000 | 0.441200 | 1.850000 |
| 119 | EasyEnsemble | Under_NearMiss | 0.035800 | 0.767200 | 0.029500 | 0.903100 | 3.090000 | 0.308500 | 4.260000 |
| 120 | Ridge_linear | Under_NearMiss | 0.025300 | 0.746500 | 0.045900 | 0.812400 | 2.570000 | 0.257000 | 1.930000 |
| 121 | CatBoost | Under_NearMiss | 0.017900 | 0.689400 | 0.021800 | 0.937800 | 1.520000 | 0.152300 | 15.120000 |
| 122 | XGBoost | Under_NearMiss | 0.013900 | 0.645500 | 0.023000 | 0.932800 | 1.000000 | 0.099700 | 2.160000 |
| 123 | GradientBoosting | Under_NearMiss | 0.013700 | 0.623900 | 0.022800 | 0.916000 | 1.360000 | 0.135500 | 5.370000 |
| 124 | LightGBM | Under_NearMiss | 0.012400 | 0.612800 | 0.021900 | 0.939500 | 0.400000 | 0.039800 | 2.930000 |
Ejecucion de experimento - Dataset PCA
file_path_results = 'results/samplers_fase1_PCA/summary_results.csv'
file_path = 'results/samplers_fase1_PCA'
if os.path.exists(file_path_results):
print(f"Cargando screening desde {file_path_results}...")
df_results = pd.read_csv(file_path_results)
else:
df_results = ejecutar_experimento_screening(df_preproc, 'es_cliente', file_path, 5, test_size=0.1)
Cargando screening desde results/samplers_fase1_PCA/summary_results.csv...
visualizar_resultados_completos(file_path_results, metricas_clave=['F1_Score', 'Recall', 'Lift_Top10'])
| Model | Sampler | PR_AUC | ROC_AUC | F1_Score | Recall | Lift_Top10 | Gain_Top10 | Time_Sec | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | RusBoost | Under_NearMiss | 0.462000 | 0.572400 | 0.023100 | 0.928900 | 1.270000 | 0.126500 | 5.030000 |
| 1 | RandomForest | Under_NearMiss | 0.430200 | 0.648900 | 0.021700 | 0.941200 | 1.650000 | 0.165200 | 2.580000 |
| 2 | BalancedRandomForest | Under_NearMiss | 0.415800 | 0.673600 | 0.021900 | 0.953500 | 1.830000 | 0.183100 | 2.890000 |
| 3 | LightGBM | Over_ROS | 0.415300 | 0.905300 | 0.390900 | 0.400900 | 7.240000 | 0.724000 | 2.030000 |
| 4 | CatBoost | Passthrough | 0.414900 | 0.899300 | 0.415300 | 0.276000 | 7.070000 | 0.707200 | 46.360000 |
| 5 | RandomForest | Passthrough | 0.411700 | 0.850500 | 0.422200 | 0.277200 | 7.010000 | 0.701000 | 21.880000 |
| 6 | RandomForest | Under_TomekLinks | 0.411400 | 0.855400 | 0.423300 | 0.280500 | 7.090000 | 0.708800 | 28.360000 |
| 7 | LightGBM | Over_SMOTE | 0.411300 | 0.904100 | 0.373800 | 0.429500 | 7.310000 | 0.731200 | 2.320000 |
| 8 | CatBoost | Under_TomekLinks | 0.411100 | 0.899800 | 0.415100 | 0.280500 | 7.150000 | 0.715000 | 48.520000 |
| 9 | LightGBM | SMOTE_Tomek | 0.409800 | 0.904200 | 0.376300 | 0.429500 | 7.300000 | 0.729600 | 8.610000 |
| 10 | CatBoost | SMOTE_Tomek | 0.402800 | 0.882900 | 0.381000 | 0.403100 | 7.000000 | 0.699900 | 53.520000 |
| 11 | RandomForest | Over_SMOTE | 0.401200 | 0.867800 | 0.402900 | 0.387500 | 7.070000 | 0.706600 | 25.870000 |
| 12 | CatBoost | Over_SMOTE | 0.400000 | 0.884000 | 0.374000 | 0.399200 | 6.970000 | 0.697100 | 49.800000 |
| 13 | LightGBM | Passthrough | 0.398200 | 0.905200 | 0.409000 | 0.274400 | 7.070000 | 0.706600 | 2.380000 |
| 14 | RandomForest | SMOTE_Tomek | 0.398100 | 0.869600 | 0.397100 | 0.379600 | 7.120000 | 0.712200 | 32.050000 |
| 15 | LightGBM | Over_BORDER_SMOTE | 0.394200 | 0.896800 | 0.264200 | 0.538100 | 7.000000 | 0.699900 | 3.070000 |
| 16 | XGBoost | Over_SMOTE | 0.393800 | 0.884600 | 0.357700 | 0.400900 | 6.950000 | 0.694800 | 2.250000 |
| 17 | LightGBM_Internal | Passthrough | 0.393100 | 0.893400 | 0.126200 | 0.706600 | 7.020000 | 0.701600 | 2.300000 |
| 18 | XGBoost | SMOTE_Tomek | 0.392900 | 0.882000 | 0.357400 | 0.400300 | 6.890000 | 0.689200 | 9.480000 |
| 19 | LightGBM | Under_TomekLinks | 0.392500 | 0.903800 | 0.414300 | 0.285000 | 7.060000 | 0.706000 | 8.560000 |
| 20 | XGBoost | Passthrough | 0.390900 | 0.882900 | 0.413900 | 0.280500 | 6.720000 | 0.672500 | 2.470000 |
| 21 | CatBoost | Over_ROS | 0.390100 | 0.867800 | 0.397800 | 0.361700 | 6.520000 | 0.651700 | 48.490000 |
| 22 | XGBoost | Under_TomekLinks | 0.390000 | 0.884400 | 0.406800 | 0.280500 | 6.800000 | 0.680300 | 8.660000 |
| 23 | XGBoost | Over_ROS | 0.388800 | 0.870500 | 0.386300 | 0.366200 | 6.380000 | 0.637700 | 2.150000 |
| 24 | CatBoost | Over_BORDER_SMOTE | 0.387300 | 0.875300 | 0.317000 | 0.459700 | 6.670000 | 0.667400 | 52.830000 |
| 25 | XGBoost | Over_BORDER_SMOTE | 0.386000 | 0.883900 | 0.291400 | 0.468600 | 6.690000 | 0.668500 | 3.170000 |
| 26 | GradientBoosting | Over_ROS | 0.384900 | 0.905800 | 0.373200 | 0.392500 | 7.240000 | 0.724000 | 41.860000 |
| 27 | CatBoost | SMOTE_ENN | 0.384100 | 0.890600 | 0.319100 | 0.436200 | 7.080000 | 0.707700 | 52.930000 |
| 28 | GradientBoosting | Over_SMOTE | 0.383800 | 0.905300 | 0.356300 | 0.407600 | 7.200000 | 0.720000 | 46.060000 |
| 29 | GradientBoosting | SMOTE_Tomek | 0.382200 | 0.905100 | 0.358700 | 0.410400 | 7.180000 | 0.718400 | 54.040000 |
| 30 | Balanced_Bagging | Under_TomekLinks | 0.376700 | 0.852200 | 0.311300 | 0.459100 | 6.750000 | 0.675300 | 11.920000 |
| 31 | Balanced_Bagging | Passthrough | 0.374700 | 0.851800 | 0.314600 | 0.460200 | 6.860000 | 0.686500 | 7.530000 |
| 32 | GradientBoosting | Passthrough | 0.372200 | 0.899300 | 0.351300 | 0.219500 | 7.170000 | 0.717200 | 40.840000 |
| 33 | XGBoost | SMOTE_ENN | 0.368100 | 0.893000 | 0.314100 | 0.432800 | 6.990000 | 0.698800 | 9.790000 |
| 34 | BalancedRandomForest | SMOTE_Tomek | 0.367200 | 0.882300 | 0.200100 | 0.605300 | 6.930000 | 0.692600 | 14.470000 |
| 35 | GradientBoosting | SMOTE_ENN | 0.367200 | 0.899800 | 0.326700 | 0.435100 | 7.200000 | 0.720000 | 50.570000 |
| 36 | GradientBoosting | Under_TomekLinks | 0.366600 | 0.898600 | 0.355600 | 0.225100 | 7.170000 | 0.717200 | 45.350000 |
| 37 | LogisticRegressionCV_linear | Under_NearMiss | 0.366400 | 0.726100 | 0.025500 | 0.875100 | 2.670000 | 0.267100 | 2.630000 |
| 38 | BalancedRandomForest | Over_SMOTE | 0.365800 | 0.882600 | 0.195800 | 0.601300 | 6.950000 | 0.695400 | 8.160000 |
| 39 | CatBoost_Internal | Passthrough | 0.365200 | 0.840100 | 0.151900 | 0.528000 | 6.060000 | 0.605800 | 50.100000 |
| 40 | RandomForest | Over_BORDER_SMOTE | 0.362800 | 0.868700 | 0.375500 | 0.393600 | 7.140000 | 0.713900 | 34.150000 |
| 41 | BalancedRandomForest | Passthrough | 0.360900 | 0.901100 | 0.106400 | 0.771000 | 7.250000 | 0.724500 | 3.360000 |
| 42 | BalancedRandomForest | Under_TomekLinks | 0.360700 | 0.900100 | 0.104300 | 0.760400 | 7.200000 | 0.719500 | 9.760000 |
| 43 | XGBoost_Internal | Passthrough | 0.358800 | 0.835500 | 0.145000 | 0.521800 | 5.900000 | 0.590100 | 2.090000 |
| 44 | GradientBoosting | Over_BORDER_SMOTE | 0.342800 | 0.897500 | 0.212000 | 0.580100 | 6.980000 | 0.698200 | 64.860000 |
| 45 | RandomForest | Over_ROS | 0.338000 | 0.833500 | 0.391900 | 0.315800 | 6.280000 | 0.628200 | 22.870000 |
| 46 | BalancedRandomForest | Over_BORDER_SMOTE | 0.334600 | 0.880400 | 0.255800 | 0.468600 | 7.210000 | 0.720600 | 24.640000 |
| 47 | RandomForest | SMOTE_ENN | 0.311100 | 0.852400 | 0.284300 | 0.414900 | 7.250000 | 0.724500 | 30.430000 |
| 48 | Balanced_Bagging | SMOTE_ENN | 0.309800 | 0.803300 | 0.264500 | 0.460800 | 6.610000 | 0.661300 | 26.460000 |
| 49 | BalancedRandomForest | SMOTE_ENN | 0.305800 | 0.873900 | 0.193900 | 0.610300 | 7.340000 | 0.733500 | 13.810000 |
| 50 | RusBoost | SMOTE_Tomek | 0.304100 | 0.846500 | 0.104600 | 0.727300 | 6.390000 | 0.638900 | 7.660000 |
| 51 | LightGBM | SMOTE_ENN | 0.292200 | 0.902700 | 0.323100 | 0.451300 | 7.250000 | 0.725100 | 9.640000 |
| 52 | RusBoost | Over_SMOTE | 0.288700 | 0.836000 | 0.103100 | 0.731200 | 6.080000 | 0.608100 | 3.670000 |
| 53 | BalancedRandomForest | Over_ROS | 0.269900 | 0.850400 | 0.154700 | 0.479300 | 6.050000 | 0.604700 | 6.380000 |
| 54 | RusBoost | Under_TomekLinks | 0.266100 | 0.834400 | 0.095500 | 0.674100 | 5.600000 | 0.560500 | 10.230000 |
| 55 | RusBoost | Over_BORDER_SMOTE | 0.247400 | 0.866100 | 0.117000 | 0.628200 | 6.300000 | 0.629900 | 3.060000 |
| 56 | SVC_linear | Passthrough | 0.247200 | 0.831000 | 0.141100 | 0.077800 | 5.880000 | 0.587900 | 1.560000 |
| 57 | LogisticRegressionCV_linear | Passthrough | 0.245400 | 0.851800 | 0.101200 | 0.053800 | 6.090000 | 0.609200 | 9.400000 |
| 58 | LogisticRegression_linear | Passthrough | 0.244900 | 0.851900 | 0.101200 | 0.053800 | 6.090000 | 0.609200 | 7.140000 |
| 59 | SVC_linear | Under_TomekLinks | 0.244500 | 0.831800 | 0.143900 | 0.079500 | 5.900000 | 0.589600 | 7.920000 |
| 60 | LogisticRegressionCV_linear | Under_TomekLinks | 0.243600 | 0.852000 | 0.102200 | 0.054300 | 6.090000 | 0.609200 | 16.590000 |
| 61 | LogisticRegression_linear | Under_TomekLinks | 0.243200 | 0.852100 | 0.101200 | 0.053800 | 6.090000 | 0.609200 | 9.880000 |
| 62 | EasyEnsemble | Over_ROS | 0.239900 | 0.887800 | 0.089000 | 0.782200 | 6.500000 | 0.650100 | 9.130000 |
| 63 | EasyEnsemble | SMOTE_Tomek | 0.236000 | 0.885300 | 0.091900 | 0.774400 | 6.540000 | 0.654000 | 18.160000 |
| 64 | RandomForest_Internal | Passthrough | 0.235800 | 0.753500 | 0.181800 | 0.276000 | 3.730000 | 0.373500 | 17.110000 |
| 65 | EasyEnsemble | SMOTE_ENN | 0.234600 | 0.888300 | 0.100500 | 0.755900 | 6.890000 | 0.688700 | 17.210000 |
| 66 | EasyEnsemble | Over_SMOTE | 0.233800 | 0.884900 | 0.090400 | 0.772100 | 6.460000 | 0.645600 | 11.800000 |
| 67 | EasyEnsemble | Under_TomekLinks | 0.232600 | 0.889800 | 0.093300 | 0.773200 | 6.610000 | 0.660700 | 9.370000 |
| 68 | LogisticRegression_linear | SMOTE_Tomek | 0.231600 | 0.861900 | 0.332300 | 0.301200 | 6.400000 | 0.640000 | 7.500000 |
| 69 | LogisticRegressionCV_linear | SMOTE_Tomek | 0.231600 | 0.861900 | 0.332400 | 0.301200 | 6.400000 | 0.640000 | 16.030000 |
| 70 | LogisticRegressionCV_linear | Over_SMOTE | 0.231100 | 0.862000 | 0.330400 | 0.300700 | 6.400000 | 0.640000 | 9.010000 |
| 71 | LogisticRegression_linear | Over_SMOTE | 0.231000 | 0.862000 | 0.330200 | 0.300100 | 6.400000 | 0.640000 | 3.590000 |
| 72 | RusBoost | SMOTE_ENN | 0.228700 | 0.862300 | 0.104000 | 0.726200 | 6.380000 | 0.638300 | 9.190000 |
| 73 | LogisticRegressionCV_linear | Over_ROS | 0.227300 | 0.861500 | 0.336000 | 0.292800 | 6.470000 | 0.646700 | 8.650000 |
| 74 | LogisticRegression_linear | Over_ROS | 0.227300 | 0.861500 | 0.334900 | 0.291700 | 6.470000 | 0.646700 | 1.050000 |
| 75 | RusBoost | Passthrough | 0.227100 | 0.853300 | 0.094300 | 0.698800 | 6.010000 | 0.601300 | 4.970000 |
| 76 | SVC_linear | SMOTE_Tomek | 0.226100 | 0.856600 | 0.313000 | 0.302400 | 6.220000 | 0.621500 | 8.680000 |
| 77 | SVC_linear | Over_SMOTE | 0.225500 | 0.856700 | 0.312000 | 0.302400 | 6.220000 | 0.622100 | 1.560000 |
| 78 | RusBoost | Over_ROS | 0.222400 | 0.870800 | 0.280700 | 0.372900 | 5.900000 | 0.590100 | 1.180000 |
| 79 | LogisticRegressionCV_linear | SMOTE_ENN | 0.221600 | 0.861000 | 0.319500 | 0.330900 | 6.480000 | 0.648400 | 16.520000 |
| 80 | LogisticRegression_linear | SMOTE_ENN | 0.221600 | 0.861000 | 0.319100 | 0.329200 | 6.480000 | 0.648400 | 7.940000 |
| 81 | SGDC_linear | Passthrough | 0.221300 | 0.851400 | 0.119400 | 0.065500 | 6.150000 | 0.615300 | 0.950000 |
| 82 | SGDC_linear | SMOTE_ENN | 0.220300 | 0.859400 | 0.313000 | 0.328100 | 6.470000 | 0.646700 | 9.150000 |
| 83 | SGDC_linear | Under_TomekLinks | 0.220100 | 0.846000 | 0.106200 | 0.057100 | 6.000000 | 0.599700 | 7.860000 |
| 84 | EasyEnsemble | Passthrough | 0.219100 | 0.889300 | 0.089200 | 0.782800 | 6.590000 | 0.659000 | 3.430000 |
| 85 | SVC_linear | Over_ROS | 0.216900 | 0.855800 | 0.320700 | 0.290600 | 6.260000 | 0.626000 | 1.690000 |
| 86 | SGDC_linear | SMOTE_Tomek | 0.209600 | 0.863400 | 0.309700 | 0.323600 | 6.460000 | 0.646100 | 7.870000 |
| 87 | SGDC_linear | Over_SMOTE | 0.208000 | 0.860400 | 0.289300 | 0.305700 | 6.360000 | 0.635500 | 1.250000 |
| 88 | SVC_linear | SMOTE_ENN | 0.204100 | 0.855800 | 0.296800 | 0.318000 | 6.280000 | 0.628200 | 9.840000 |
| 89 | LogisticRegressionCV_linear | Over_BORDER_SMOTE | 0.194600 | 0.862300 | 0.140600 | 0.577300 | 6.480000 | 0.647800 | 10.630000 |
| 90 | LogisticRegression_linear | Over_BORDER_SMOTE | 0.194600 | 0.862300 | 0.140600 | 0.577300 | 6.480000 | 0.647800 | 1.760000 |
| 91 | SGDC_linear | Over_ROS | 0.191800 | 0.861500 | 0.287400 | 0.306300 | 6.430000 | 0.642800 | 1.180000 |
| 92 | PassiveAggressive_linear | Passthrough | 0.183600 | 0.793400 | 0.042000 | 0.021800 | 5.460000 | 0.545900 | 1.590000 |
| 93 | LogisticRegressionCV_Internal_linear | Passthrough | 0.180100 | 0.862900 | 0.084300 | 0.769900 | 6.360000 | 0.635500 | 7.990000 |
| 94 | LogisticRegression_Internal_linear | Passthrough | 0.180100 | 0.862900 | 0.084200 | 0.769900 | 6.360000 | 0.635500 | 0.890000 |
| 95 | Ridge_linear | SMOTE_Tomek | 0.176600 | 0.850700 | 0.301900 | 0.258700 | 6.100000 | 0.609700 | 7.380000 |
| 96 | Ridge_linear | Over_SMOTE | 0.176000 | 0.850600 | 0.298500 | 0.256400 | 6.090000 | 0.609200 | 0.880000 |
| 97 | PassiveAggressive_linear | Under_TomekLinks | 0.173800 | 0.800200 | 0.025900 | 0.013400 | 5.460000 | 0.546500 | 7.740000 |
| 98 | SGDC_linear | Over_BORDER_SMOTE | 0.171600 | 0.861300 | 0.142400 | 0.577800 | 6.500000 | 0.650100 | 1.840000 |
| 99 | SVC_Internal_linear | Passthrough | 0.168800 | 0.857000 | 0.051500 | 0.026900 | 6.260000 | 0.626000 | 1.390000 |
| 100 | Ridge_linear | Over_ROS | 0.167900 | 0.849700 | 0.276800 | 0.230700 | 6.110000 | 0.611400 | 0.890000 |
| 101 | Ridge_linear | Passthrough | 0.167200 | 0.839700 | 0.072500 | 0.040300 | 6.060000 | 0.606400 | 0.790000 |
| 102 | PassiveAggressive_linear | SMOTE_ENN | 0.164800 | 0.839100 | 0.275000 | 0.252500 | 6.280000 | 0.628200 | 9.570000 |
| 103 | PassiveAggressive_linear | SMOTE_Tomek | 0.164800 | 0.850500 | 0.230600 | 0.169700 | 6.170000 | 0.617000 | 8.670000 |
| 104 | Ridge_linear | Under_TomekLinks | 0.164400 | 0.840000 | 0.071000 | 0.039800 | 6.060000 | 0.606400 | 7.010000 |
| 105 | SVC_linear | Over_BORDER_SMOTE | 0.164300 | 0.858500 | 0.125100 | 0.597400 | 6.390000 | 0.638900 | 2.240000 |
| 106 | PassiveAggressive_linear | Over_SMOTE | 0.158400 | 0.847000 | 0.226200 | 0.156800 | 6.040000 | 0.603600 | 2.040000 |
| 107 | PassiveAggressive_linear | Over_BORDER_SMOTE | 0.152600 | 0.857400 | 0.209700 | 0.457400 | 6.230000 | 0.623200 | 3.450000 |
| 108 | Ridge_linear | SMOTE_ENN | 0.151300 | 0.846800 | 0.304800 | 0.292800 | 6.140000 | 0.613700 | 8.460000 |
| 109 | EasyEnsemble | Over_BORDER_SMOTE | 0.139600 | 0.878500 | 0.085800 | 0.750300 | 6.160000 | 0.616500 | 39.070000 |
| 110 | Ridge_Internal_linear | Passthrough | 0.136900 | 0.850100 | 0.018700 | 0.009500 | 5.580000 | 0.558200 | 0.820000 |
| 111 | Ridge_linear | Over_BORDER_SMOTE | 0.129400 | 0.850900 | 0.111500 | 0.586200 | 5.970000 | 0.596900 | 1.380000 |
| 112 | PassiveAggressive_linear | Over_ROS | 0.124000 | 0.845400 | 0.192500 | 0.146100 | 6.290000 | 0.628800 | 2.340000 |
| 113 | SGDC_Internal_linear | Passthrough | 0.116200 | 0.856500 | 0.080500 | 0.755300 | 6.300000 | 0.629900 | 2.220000 |
| 114 | SVC_linear | Under_NearMiss | 0.115900 | 0.738900 | 0.026300 | 0.835900 | 3.460000 | 0.346000 | 2.080000 |
| 115 | PassiveAggressive_linear | Under_NearMiss | 0.096800 | 0.727000 | 0.027100 | 0.828700 | 3.070000 | 0.307400 | 2.110000 |
| 116 | PassiveAggressive_Internal_linear | Passthrough | 0.095000 | 0.816200 | 0.000000 | 0.000000 | 4.620000 | 0.461900 | 1.910000 |
| 117 | LogisticRegression_linear | Under_NearMiss | 0.066500 | 0.736600 | 0.031200 | 0.802900 | 3.170000 | 0.316900 | 4.800000 |
| 118 | SGDC_linear | Under_NearMiss | 0.059200 | 0.737200 | 0.031500 | 0.801200 | 3.230000 | 0.323100 | 1.890000 |
| 119 | Ridge_linear | Under_NearMiss | 0.045300 | 0.730600 | 0.028500 | 0.793400 | 3.480000 | 0.347700 | 1.860000 |
| 120 | EasyEnsemble | Under_NearMiss | 0.030900 | 0.743800 | 0.022800 | 0.936700 | 2.910000 | 0.291200 | 4.110000 |
| 121 | CatBoost | Under_NearMiss | 0.030800 | 0.744100 | 0.021800 | 0.940600 | 3.670000 | 0.366700 | 17.450000 |
| 122 | LightGBM | Under_NearMiss | 0.022700 | 0.732900 | 0.021700 | 0.941800 | 2.590000 | 0.258700 | 2.790000 |
| 123 | GradientBoosting | Under_NearMiss | 0.016000 | 0.674800 | 0.021800 | 0.938400 | 1.520000 | 0.151700 | 3.440000 |
| 124 | XGBoost | Under_NearMiss | 0.015200 | 0.667300 | 0.021700 | 0.941800 | 1.250000 | 0.125400 | 2.140000 |
Con el fin de verificar la capacidad de generalización de los modelos mejor clasificados durante el proceso de validación cruzada, se realizó una evaluación adicional sobre un conjunto de test independiente para el dataset genérico
analizar_top_5(df_preproc, 'es_cliente', 'results/samplers_fase1')
================================================================================
ANÁLISIS DETALLADO TOP 3 MODELOS - EVALUACIÓN FINAL EN Conjunto de Test (Hold-out)
================================================================================
> Evaluando: #1 CatBoost (Passthrough)
Tiempo de entrenamiento final: 11.77 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4340 (Métrica de Ref.)
ROC-AUC: 0.9080
Lift@10%: 7.22
Gain@10%: 0.7222
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.92 0.25 0.39 198
accuracy 0.99 19517
macro avg 0.96 0.62 0.69 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #2 RusBoost (Under_NearMiss)
Tiempo de entrenamiento final: 2.42 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4712 (Métrica de Ref.)
ROC-AUC: 0.6930
Lift@10%: 1.97
Gain@10%: 0.1970
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.46 0.63 19319
1 0.02 0.92 0.03 198
accuracy 0.47 19517
macro avg 0.51 0.69 0.33 19517
weighted avg 0.99 0.47 0.63 19517
------------------------------------------------------------
> Evaluando: #3 CatBoost (Under_TomekLinks)
Tiempo de entrenamiento final: 34.44 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4345 (Métrica de Ref.)
ROC-AUC: 0.9138
Lift@10%: 7.17
Gain@10%: 0.7172
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.84 0.25 0.38 198
accuracy 0.99 19517
macro avg 0.92 0.62 0.69 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #4 LightGBM (Over_ROS)
Tiempo de entrenamiento final: 0.46 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4292 (Métrica de Ref.)
ROC-AUC: 0.9171
Lift@10%: 7.43
Gain@10%: 0.7424
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 0.99 0.99 19319
1 0.43 0.42 0.43 198
accuracy 0.99 19517
macro avg 0.71 0.71 0.71 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #5 BalancedRandomForest (Under_NearMiss)
Tiempo de entrenamiento final: 0.81 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4290 (Métrica de Ref.)
ROC-AUC: 0.6886
Lift@10%: 2.02
Gain@10%: 0.2020
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.15 0.26 19319
1 0.01 0.96 0.02 198
accuracy 0.16 19517
macro avg 0.50 0.55 0.14 19517
weighted avg 0.99 0.16 0.26 19517
------------------------------------------------------------ Generando Curvas de Negocio Comparativas (Gain & Lift) en el Conjunto de Test...
Análisis completado.
📌 Conclusiones
El análisis comparativo entre el dataset original y su versión transformada mediante PCA muestra de forma consistente que el dataset sin reducción de dimensionalidad ofrece un mayor poder predictivo, especialmente en modelos no lineales y basados en árboles como CatBoost y LightGBM. La aplicación de PCA, aunque útil para modelos lineales, implica una pérdida de información relevante para la detección de la clase minoritaria, penalizando métricas clave como PR-AUC y Lift.
Adicionalmente, el dataset genérico presenta ventajas claras en términos de interpretabilidad, trazabilidad de variables y reutilización en fases posteriores del proyecto. Por estos motivos, se adopta el dataset sin PCA como referencia para las siguientes etapas de modelado y optimización.
Modelos con mejor rendimiento (dataset sin PCA)
- CatBoost + Passthrough: mejor PR-AUC global (0.4393) y Lift competitivo (7.27).
- RUSBoost + Under_NearMiss: destaca por un recall muy elevado (88.07%), aunque con importantes limitaciones de negocio.
- LightGBM + RandomOverSampler: equilibrio sólido entre PR-AUC (0.4215) y Lift (7.35).
Limitaciones detectadas
- NearMiss provoca una degradación severa del Lift, incluso en modelos robustos (por ejemplo, CatBoost desciende de 7.27 a 1.52).
- La aplicación de PCA reduce de forma significativa el PR-AUC en los modelos con mejor desempeño.
- RUSBoost maximiza recall, pero genera resultados poco accionables debido a su bajo Lift.
- Los modelos lineales muestran un rendimiento limitado, tanto con como sin balanceo interno.
- Algunas técnicas presentan un coste computacional elevado (TomekLinks, SMOTE-ENN, SMOTE-Tomek), así como CatBoost en menor medida.
Hallazgos clave
- Varios modelos muestran un rendimiento competitivo sin necesidad de aplicar sampling externo.
- Los enfoques de balanceo moderados o “suaves” tienden a mejorar las métricas sin penalizar el Lift.
- CatBoost se consolida como un modelo consistente y robusto en distintos escenarios.
- El undersampling agresivo compromete seriamente las métricas de negocio, pese a mejorar recall.
Selección de modelos para fases posteriores
A partir de estos resultados se descartan configuraciones basadas en NearMiss, debido a su baja precisión y escaso valor operativo. Se seleccionan dos modelos con alto rendimiento global y perfiles complementarios, adecuados para ser combinados en un esquema de ensamblaje:
- CatBoost (Passthrough): maximiza el PR-AUC y ofrece una señal robusta y estable.
- LightGBM (RandomOverSampler): proporciona un equilibrio sólido entre métricas predictivas y métricas de negocio.
Tratamiento del desbalance
Tras analizar exhaustivamente el rendimiento de los modelos y configuraciones de muestreo, se observa que CatBoost en modo passthrough destaca de forma consistente por su elevada capacidad predictiva, su estabilidad en métricas clave (especialmente PR-AUC y Lift) y, de forma notable, por su bajo coste computacional relativo. Estas características lo convierten en el candidato más sólido para liderar la siguiente fase del pipeline de modelado.
En paralelo, se ha comprobado que LightGBM combinado con Random Oversampling (ROS) produce resultados altamente competitivos, con un rendimiento estable en PR-AUC y Lift y una variabilidad controlada según el nivel de oversampling. El análisis sugiere que configuraciones con ROS entre el 0.5 y el 1.0 representan un punto óptimo entre precisión, recall y eficiencia temporal. No obstante, el desempeño del LightGBM en modo passthrough muestra que esta alternativa también puede resultar competitiva y no debe descartarse en fases posteriores, especialmente en estructuras de ensamblado como stacking. En este apartado, revisaremos las mejores configuraciones de ROS para LightGBM.
# ============================================================================
# Re-Definicion de modelos
# ============================================================================
def get_model_configs(random_state=42):
# Modelos lineales
lr = LogisticRegression(solver='liblinear', random_state=random_state)
# Modelos Base
lgbm = LGBMClassifier(n_jobs=-1, random_state=random_state, verbosity=-1)
#cat = CatBoostClassifier(verbose=0, random_state=random_state, allow_writing_files=False)
#Ensambles
rusb = RUSBoostClassifier(estimator=DecisionTreeClassifier(max_depth=3), n_estimators=50, learning_rate=1.0,
sampling_strategy=None, random_state=42
)
models = {
'LightGBM': lgbm,
#'CatBoost': cat
}
return models
# ============================================================================
# Re-Definicion de samplers
# ============================================================================
def get_sampler_configs(random_state=42):
return {
# Sin samplers
'Passthrough': 'passthrough',
# Over Sampling
'Over_ROS_02': RandomOverSampler(sampling_strategy=0.02, random_state=random_state),
'Over_ROS_03': RandomOverSampler(sampling_strategy=0.03, random_state=random_state),
'Over_ROS_05': RandomOverSampler(sampling_strategy=0.05, random_state=random_state),
'Over_ROS_1': RandomOverSampler(sampling_strategy=0.1, random_state=random_state),
'Over_ROS_2': RandomOverSampler(sampling_strategy=0.2, random_state=random_state),
'Over_ROS_3': RandomOverSampler(sampling_strategy=0.3, random_state=random_state),
}
file_path_results = 'results/samplers_fase2/summary_results.csv'
file_path = 'results/samplers_fase2'
#df_results = ejecutar_experimento_screening(df_preproc, 'es_cliente', file_path, 5, test_size=0.1)
file_path_results = 'results/samplers_fase2/summary_results.csv'
file_path = 'results/samplers_fase2'
visualizar_resultados_completos(file_path_results, metricas_clave=['F1_Score', 'Recall', 'Lift_Top10'])
| Model | Sampler | PR_AUC | ROC_AUC | F1_Score | Recall | Lift_Top10 | Gain_Top10 | Time_Sec | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | LightGBM | Over_ROS_05 | 0.422100 | 0.910700 | 0.436200 | 0.350500 | 7.360000 | 0.735700 | 4.320000 |
| 1 | LightGBM | Over_ROS_1 | 0.421500 | 0.911000 | 0.395700 | 0.426100 | 7.350000 | 0.734600 | 2.220000 |
| 2 | LightGBM | Over_ROS_02 | 0.419300 | 0.909100 | 0.421600 | 0.288900 | 7.250000 | 0.725100 | 5.410000 |
| 3 | LightGBM | Over_ROS_03 | 0.417300 | 0.910200 | 0.420800 | 0.299000 | 7.360000 | 0.736300 | 5.340000 |
| 4 | LightGBM | Over_ROS_2 | 0.415600 | 0.910000 | 0.314200 | 0.522400 | 7.320000 | 0.731800 | 2.380000 |
| 5 | LightGBM | Passthrough | 0.413700 | 0.909400 | 0.408100 | 0.275500 | 7.280000 | 0.727900 | 5.900000 |
| 6 | LightGBM | Over_ROS_3 | 0.409900 | 0.908400 | 0.250700 | 0.574500 | 7.210000 | 0.720600 | 2.520000 |
analizar_top_5(df_preproc, 'es_cliente', 'results/samplers_fase2')
================================================================================
ANÁLISIS DETALLADO TOP 3 MODELOS - EVALUACIÓN FINAL EN Conjunto de Test (Hold-out)
================================================================================
> Evaluando: #1 LightGBM (Over_ROS_05)
Tiempo de entrenamiento final: 0.40 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4296 (Métrica de Ref.)
ROC-AUC: 0.9196
Lift@10%: 7.48
Gain@10%: 0.7475
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.66 0.33 0.44 198
accuracy 0.99 19517
macro avg 0.83 0.66 0.72 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #2 LightGBM (Over_ROS_1)
Tiempo de entrenamiento final: 0.41 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4292 (Métrica de Ref.)
ROC-AUC: 0.9171
Lift@10%: 7.43
Gain@10%: 0.7424
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 0.99 0.99 19319
1 0.43 0.42 0.43 198
accuracy 0.99 19517
macro avg 0.71 0.71 0.71 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #3 LightGBM (Over_ROS_02)
Tiempo de entrenamiento final: 0.45 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4277 (Métrica de Ref.)
ROC-AUC: 0.9203
Lift@10%: 7.33
Gain@10%: 0.7323
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.84 0.28 0.42 198
accuracy 0.99 19517
macro avg 0.91 0.64 0.71 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #4 LightGBM (Over_ROS_03)
Tiempo de entrenamiento final: 0.59 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4277 (Métrica de Ref.)
ROC-AUC: 0.9167
Lift@10%: 7.28
Gain@10%: 0.7273
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.74 0.29 0.41 198
accuracy 0.99 19517
macro avg 0.87 0.64 0.71 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #5 LightGBM (Over_ROS_2)
Tiempo de entrenamiento final: 0.60 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4174 (Métrica de Ref.)
ROC-AUC: 0.9170
Lift@10%: 7.38
Gain@10%: 0.7374
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.98 0.99 19319
1 0.22 0.54 0.31 198
accuracy 0.98 19517
macro avg 0.61 0.76 0.65 19517
weighted avg 0.99 0.98 0.98 19517
------------------------------------------------------------ Generando Curvas de Negocio Comparativas (Gain & Lift) en el Conjunto de Test...
Análisis completado.
📌Conclusiones
Se selecciona LightGBM en modo passthrough debido a su simplicidad y ausencia de distorsiones inducidas por técnicas de muestreo, lo que permite disponer de una referencia limpia y estable. Asimismo, se incorporan al screening los dos modelos mejor clasificados con técnicas de oversampling (ROS 0.10 y ROS 0.05), dado que representan un equilibrio adecuado entre precisión, recall y estabilidad.
A priori, el modelo con ROS 0.10, que exhibe un recall superior, podría complementar de forma más efectiva a CatBoost dentro de esquemas de ensamblado, proporcionando una combinación más robusta entre sensibilidad y precisión. En el siguiente apartado se explorarán técnicas avanzadas de fusión de modelos (stacking y variantes), con el objetivo de identificar sinergias entre estos clasificadores y maximizar el rendimiento global.
Evaluacion de ensambles
Se evalúan diversas técnicas de ensamblado de modelos aplicadas a los clasificadores más prometedores identificados en fases previas —LightGBM y CatBoost— mediante estrategias de Stacking, Voting y Bagging. El objetivo principal de este análisis es determinar si la combinación estructurada de modelos permite mejorar de forma consistente las métricas clave del problema, en particular PR-AUC y recall, en un contexto caracterizado por un fuerte desbalance de clases.
La literatura reciente ha mostrado que los métodos de ensamblado pueden ofrecer ganancias significativas en tareas de predicción de propensión de compra y clasificación desbalanceada, al combinar modelos con sesgos inductivos complementarios y reducir la varianza de las predicciones (Singh et al., 2024; Liu et al., 2024). En este trabajo, se exploran múltiples configuraciones que varían tanto el tipo de ensamblado como el tratamiento del desbalance aplicado a los clasificadores base.
-
Stacking de primer nivel, que combina CatBoost (passthrough) con distintas variantes de LightGBM, incluyendo configuraciones con oversampling explícito (RandomOverSampler con proporciones del
0.05,0.10y0.20) y versiones sin muestreo, con el fin de preservar la distribución original de la clase mayoritaria. - Stacking con mecanismos internos de balanceo, en el que ambos modelos se evalúan utilizando sus estrategias internas de compensación del desbalance (auto_class_weights en CatBoost, scale_pos_weight y class_weight='balanced' en LightGBM). Estas configuraciones permiten comparar el efecto del balanceo implícito frente al oversampling externo.
- Voting Classifier con soft voting, planteado como una alternativa más sencilla y transparente, donde las probabilidades predichas por ambos modelos se combinan mediante un promedio ponderado. Se asigna un mayor peso a CatBoost, atendiendo a su mejor rendimiento individual observado en fases previas.
- Bagging de modelos base, aplicado tanto a CatBoost como a LightGBM mediante la generación de múltiples clasificadores entrenados sobre distintas muestras bootstrap. Se incluyen variantes con diferente número de estimadores, porcentajes de muestreo reducidos (70%) y configuraciones optimizadas para reducir el coste computacional.
El conjunto de configuraciones evaluadas permite llevar a cabo un screening exhaustivo de arquitecturas de ensamblado, comparando de forma sistemática cómo interactúan ambos modelos bajo distintas estrategias de combinación, balanceo y agregación. Esta fase resulta clave para identificar soluciones que no solo maximicen el rendimiento medio, sino que presenten un comportamiento estable y robusto frente al desbalance extremo del problema.
from sklearn.ensemble import StackingClassifier, VotingClassifier
from sklearn.linear_model import LogisticRegression
from imblearn.over_sampling import RandomOverSampler
from imblearn.pipeline import make_pipeline
from catboost import CatBoostClassifier
import numpy as np
from sklearn.ensemble import BaggingClassifier
def get_model_configs(random_state=42):
"""
Configuraciones de stacking y ensembles para probar
"""
# -----------------------------------------------------
# 1. STACKING BÁSICOS (CatBoost + LightGBM)
# -----------------------------------------------------
# Stacking 1.0: CatBoost (Passthrough) + LightGBM (ROS)
# Objetivo: Combinar precisión de CatBoost con recall de LightGBM+ROS
catboost_base = CatBoostClassifier(
random_state=random_state,
allow_writing_files=False, # ← EVITA crear directorios
train_dir=None # ← También importante
)
lgbm_base = LGBMClassifier(
random_state=random_state
)
lightgbm_ros_1 = make_pipeline(
RandomOverSampler(sampling_strategy=0.1, random_state=random_state),
LGBMClassifier(
random_state=random_state
)
)
stacking_cb_lgb_ros_1 = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm_ros', lightgbm_ros_1)
],
final_estimator=LogisticRegression(
solver='liblinear',
random_state=random_state,
class_weight='balanced'
),
cv=3,
n_jobs=-1,
stack_method='predict_proba'
)
# Stacking 1.1: CatBoost (Passthrough) + LightGBM (ROS)
# Objetivo: Combinar precisión de CatBoost con recall de LightGBM+ROS
lightgbm_ros_05 = make_pipeline(
RandomOverSampler(sampling_strategy=0.05, random_state=random_state),
LGBMClassifier(
random_state=random_state
)
)
stacking_cb_lgb_ros_05 = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm_ros', lightgbm_ros_05)
],
final_estimator=LogisticRegression(
solver='liblinear',
random_state=random_state,
class_weight='balanced'
),
cv=3,
n_jobs=-1,
stack_method='predict_proba'
)
# Stacking 1.2: CatBoost (Passthrough) + LightGBM (ROS)
# Objetivo: Combinar precisión de CatBoost con recall de LightGBM+ROS
lightgbm_ros_2 = make_pipeline(
RandomOverSampler(sampling_strategy=0.2, random_state=random_state),
LGBMClassifier(
random_state=random_state
)
)
stacking_cb_lgb_ros_2 = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm_ros', lightgbm_ros_2)
],
final_estimator=LogisticRegression(
solver='liblinear',
random_state=random_state,
class_weight='balanced'
),
cv=3,
n_jobs=-1,
stack_method='predict_proba'
)
# Stacking 2: Ambos con Passthrough
# Objetivo: Combinar sin alterar distribución original
stacking_cb_lgb = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', LGBMClassifier(
random_state=random_state
))
],
final_estimator=LogisticRegression(
solver='liblinear',
random_state=random_state,
class_weight='balanced'
),
cv=3,
n_jobs=-1
)
# AutoBalancing
cb_balanced_auto = CatBoostClassifier(auto_class_weights='Balanced', random_state=random_state)
cb_balanced_scale = CatBoostClassifier(scale_pos_weight=98, random_state=random_state)
lgbm_balanced_auto = LGBMClassifier(class_weight='balanced', random_state=random_state)
lgbm_balanced_scale = LGBMClassifier(scale_pos_weight=98, random_state=random_state)
#Meta
lr = LogisticRegression(
solver='liblinear',
random_state=random_state,
class_weight='balanced'
)
#Stacking 3: Con balanceos internos
stacking_cb_lgb_ibscale = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_balanced_scale)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_ibauto = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_balanced_auto)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibauto_lgb = StackingClassifier(
estimators=[
('catboost', cb_balanced_auto),
('lightgbm', lgbm_base)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibscale_lgb = StackingClassifier(
estimators=[
('catboost', cb_balanced_scale),
('lightgbm', lgbm_base)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibscale_lgb_ibscale = StackingClassifier(
estimators=[
('catboost', cb_balanced_scale),
('lightgbm', lgbm_balanced_scale)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibscale_lgb_ibauto = StackingClassifier(
estimators=[
('catboost', cb_balanced_scale),
('lightgbm', lgbm_balanced_auto)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibauto_lgb_ibscale = StackingClassifier(
estimators=[
('catboost', cb_balanced_auto),
('lightgbm', lgbm_balanced_scale)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
stacking_cb_ibauto_lgb_ibauto = StackingClassifier(
estimators=[
('catboost', cb_balanced_auto),
('lightgbm', lgbm_balanced_auto)
],
final_estimator=lr,
cv=3,
n_jobs=-1
)
# -----------------------------------------------------
# 3. VOTING CLASSIFIER (Alternativa a Stacking)
# -----------------------------------------------------
# Soft Voting: Promedio de probabilidades
# Objetivo: Simplicidad y robustez
voting_soft = VotingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', LGBMClassifier(
random_state=random_state,
n_jobs=-1,
n_estimators=200
))
],
voting='soft',
weights=[1.0, 0.8], # Peso mayor a CatBoost (más preciso)
n_jobs=-1
)
# -----------------------------------------------------
# 5. BAGGING DE MODELOS (Meta-Ensemble)
# -----------------------------------------------------
# ========== BAGGING BÁSICO ==========
# A) Bagging de CatBoost
Bagging_CatBoost = BaggingClassifier(
estimator=catboost_base,
n_estimators=10, # 10 modelos
random_state=random_state,
n_jobs=-1 # Paralelizar los 10 modelos
)
# B) Bagging de LightGBM
Bagging_LightGBM_10 = BaggingClassifier(
estimator=LGBMClassifier(
random_state=random_state
),
n_estimators=10,
random_state=random_state,
n_jobs=-1
)
# ========== BAGGING CON BOOTSTRAP DIFERENTE ==========
# C) Bagging con menos muestras (70%)
Bagging_CatBoost_70pct = BaggingClassifier(
estimator=catboost_base,
n_estimators=10,
max_samples=0.7, # Solo 70% de datos por modelo
random_state=random_state,
n_jobs=-1
)
# ========== BAGGING CON MÁS MODELOS ==========
# E) Bagging con más estimadores (pero más rápido)
Bagging_LightGBM_40 = BaggingClassifier(
estimator=LGBMClassifier(
n_estimators=50, # Modelos base más simples
random_state=random_state,
n_jobs=1,
verbose=-1
),
n_estimators=40, # 20 modelos LightGBM
random_state=random_state,
n_jobs=-1
)
# -----------------------------------------------------
# RETORNAR TODAS LAS CONFIGURACIONES
# -----------------------------------------------------
return {
# Stackings básicos
'Stacking_CatBoost_LightGBM_ROS_05': stacking_cb_lgb_ros_05,
'Stacking_CatBoost_LightGBM_ROS_2': stacking_cb_lgb_ros_2,
'Stacking_CatBoost_LightGBM': stacking_cb_lgb,
'Stacking_CatBoost_LightGBM_InternalBalanceAuto': stacking_cb_lgb_ibauto,
'Stacking_CatBoost_LightGBM_InternalBalanceScale': stacking_cb_lgb_ibscale,
'Stacking_CatBoost_InternalBalanceAuto_LightGBM': stacking_cb_ibauto_lgb,
'Stacking_CatBoost_InternalBalanceScale_LightGBM': stacking_cb_ibscale_lgb,
'Stacking_CatBoost_InternalBalanceScale_LightGBM_InternalBalanceScale': stacking_cb_ibscale_lgb_ibscale,
'Stacking_CatBoost_InternalBalanceAuto_LightGBM_InternalBalanceScale': stacking_cb_ibauto_lgb_ibscale,
'Stacking_CatBoost_InternalBalanceAuto_LightGBM_InternalBalanceAuto': stacking_cb_ibauto_lgb_ibauto,
'Stacking_CatBoost_InternalBalanceScale_LightGBM_InternalBalanceAuto': stacking_cb_ibscale_lgb_ibauto,
# Voting classifiers
'Voting_Soft_CatBoost_LightGBM': voting_soft,
# Ensembles avanzados
'Bagging_CatBoost': Bagging_CatBoost,
'Bagging_LightGBM_40': Bagging_LightGBM_40
}
# ============================================================================
# Re-Definicion de samplers
# ============================================================================
def get_sampler_configs(random_state=42):
return {
# Sin samplers
'Passthrough': 'passthrough',
}
file_path_results = 'results/samplers_fase3/summary_results.csv'
file_path = 'results/samplers_fase3'
#df_results = ejecutar_experimento_screening(df_preproc, 'es_cliente', file_path, 5, test_size=0.1)
file_path_results = 'results/samplers_fase3/summary_results.csv'
file_path = 'results/samplers_fase3'
visualizar_resultados_completos(file_path_results, metricas_clave=['F1_Score', 'Recall', 'Lift_Top10'])
| Model | Sampler | PR_AUC | ROC_AUC | F1_Score | Recall | Lift_Top10 | Gain_Top10 | Time_Sec | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Stacking_CatBoost_LightGBM | Passthrough | 0.440800 | 0.912600 | 0.205500 | 0.649500 | 7.370000 | 0.736800 | 152.010000 |
| 1 | Voting_Soft_CatBoost_LightGBM | Passthrough | 0.438600 | 0.911700 | 0.430600 | 0.285600 | 7.270000 | 0.726800 | 49.940000 |
| 2 | Bagging_CatBoost | Passthrough | 0.438300 | 0.907100 | 0.422900 | 0.272700 | 7.230000 | 0.722800 | 409.470000 |
| 3 | Stacking_CatBoost_LightGBM_ROS_05 | Passthrough | 0.436200 | 0.912500 | 0.169200 | 0.691500 | 7.410000 | 0.740800 | 136.980000 |
| 4 | Stacking_CatBoost_LightGBM_InternalBalanceAuto | Passthrough | 0.434300 | 0.905200 | 0.100200 | 0.772700 | 7.180000 | 0.717800 | 142.620000 |
| 5 | Stacking_CatBoost_LightGBM_InternalBalanceScale | Passthrough | 0.433400 | 0.895000 | 0.105600 | 0.741300 | 7.020000 | 0.701600 | 152.550000 |
| 6 | Stacking_CatBoost_LightGBM_ROS_2 | Passthrough | 0.433200 | 0.911400 | 0.139900 | 0.727300 | 7.320000 | 0.732400 | 144.910000 |
| 7 | Bagging_LightGBM_40 | Passthrough | 0.432200 | 0.913600 | 0.391200 | 0.248000 | 7.470000 | 0.746900 | 23.690000 |
| 8 | Stacking_CatBoost_InternalBalanceScale_LightGBM | Passthrough | 0.406900 | 0.886100 | 0.117300 | 0.696000 | 6.840000 | 0.684200 | 153.100000 |
| 9 | Stacking_CatBoost_InternalBalanceAuto_LightGBM | Passthrough | 0.404400 | 0.886000 | 0.117300 | 0.695400 | 6.850000 | 0.684800 | 157.230000 |
| 10 | Stacking_CatBoost_InternalBalanceScale_LightGBM_InternalBalanceScale | Passthrough | 0.378200 | 0.884100 | 0.089100 | 0.771600 | 6.760000 | 0.675800 | 141.850000 |
| 11 | Stacking_CatBoost_InternalBalanceAuto_LightGBM_InternalBalanceScale | Passthrough | 0.376700 | 0.883900 | 0.089200 | 0.770400 | 6.740000 | 0.673600 | 156.270000 |
| 12 | Stacking_CatBoost_InternalBalanceScale_LightGBM_InternalBalanceAuto | Passthrough | 0.356500 | 0.901300 | 0.085800 | 0.795600 | 7.120000 | 0.711600 | 161.770000 |
| 13 | Stacking_CatBoost_InternalBalanceAuto_LightGBM_InternalBalanceAuto | Passthrough | 0.353200 | 0.901300 | 0.085600 | 0.795100 | 7.110000 | 0.710500 | 148.360000 |
analizar_top_5(df_preproc, 'es_cliente', 'results/samplers_fase3')
================================================================================
ANÁLISIS DETALLADO TOP 3 MODELOS - EVALUACIÓN FINAL EN Conjunto de Test (Hold-out)
================================================================================
> Evaluando: #1 Stacking_CatBoost_LightGBM (Passthrough)
Tiempo de entrenamiento final: 37.76 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4361 (Métrica de Ref.)
ROC-AUC: 0.9183
Lift@10%: 7.43
Gain@10%: 0.7424
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.95 0.97 19319
1 0.12 0.66 0.20 198
accuracy 0.95 19517
macro avg 0.56 0.80 0.59 19517
weighted avg 0.99 0.95 0.97 19517
------------------------------------------------------------
> Evaluando: #2 Voting_Soft_CatBoost_LightGBM (Passthrough)
Tiempo de entrenamiento final: 17.23 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4396 (Métrica de Ref.)
ROC-AUC: 0.9132
Lift@10%: 7.53
Gain@10%: 0.7525
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.89 0.25 0.39 198
accuracy 0.99 19517
macro avg 0.94 0.63 0.69 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #3 Bagging_CatBoost (Passthrough)
Tiempo de entrenamiento final: 108.22 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4459 (Métrica de Ref.)
ROC-AUC: 0.9093
Lift@10%: 7.38
Gain@10%: 0.7374
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.96 0.22 0.36 198
accuracy 0.99 19517
macro avg 0.97 0.61 0.68 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #4 Stacking_CatBoost_LightGBM_ROS_05 (Passthrough)
Tiempo de entrenamiento final: 37.40 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4392 (Métrica de Ref.)
ROC-AUC: 0.9201
Lift@10%: 7.48
Gain@10%: 0.7475
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.93 0.96 19319
1 0.09 0.69 0.17 198
accuracy 0.93 19517
macro avg 0.55 0.81 0.56 19517
weighted avg 0.99 0.93 0.96 19517
------------------------------------------------------------
> Evaluando: #5 Stacking_CatBoost_LightGBM_InternalBalanceAuto (Passthrough)
Tiempo de entrenamiento final: 38.85 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4383 (Métrica de Ref.)
ROC-AUC: 0.9108
Lift@10%: 7.33
Gain@10%: 0.7323
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.87 0.93 19319
1 0.06 0.79 0.11 198
accuracy 0.86 19517
macro avg 0.53 0.83 0.52 19517
weighted avg 0.99 0.86 0.92 19517
------------------------------------------------------------ Generando Curvas de Negocio Comparativas (Gain & Lift) en el Conjunto de Test...
Análisis completado.
📌Conclusiones
Los resultados obtenidos confirman que la aplicación de técnicas de stacking mejora de forma consistente el rendimiento predictivo del sistema. Además, se observa una baja divergencia entre las métricas de validación y las del conjunto de test, lo que indica un buen comportamiento en términos de generalización.
De manera destacada, las configuraciones entrenadas sobre los datasets sin alterar —esto es, sin aplicar técnicas de muestreo ni mecanismos internos de balanceo— presentan los mejores resultados globales. Este comportamiento sugiere que preservar la distribución original de los datos permite a los modelos capturar patrones relevantes sin introducir ruido artificial ni distorsiones en la señal predictiva.
Entre todas las combinaciones evaluadas, el stacking de CatBoost y LightGBM sin balanceo interno ni muestreo externo emerge como la alternativa más equilibrada. Esta configuración aprovecha de forma complementaria la alta precisión característica de CatBoost y el mayor recall aportado por LightGBM cuando se entrena sobre datos no manipulados.
Si bien algunas configuraciones alcanzan valores de lift ligeramente superiores o muestran un rendimiento puntual algo mejor sobre el conjunto de test —como Stacking_CatBoost_LightGBM_ROS_05—, se selecciona finalmente Stacking_CatBoost_LightGBM por su alto rendimiento global, la ausencia de datos generados artificialmente y su mayor adecuación para fases posteriores de optimización, interpretación y explotación del modelo.
Selección y evaluación del meta-estimador en el esquema de stacking
En esta fase se analiza el impacto de distintos meta-estimadores sobre el rendimiento del esquema de stacking previamente seleccionado. El objetivo principal es identificar qué modelo final —responsable de integrar las predicciones generadas por CatBoost y LightGBM— proporciona la mayor capacidad discriminativa y estabilidad bajo un marco de validación consistente.
Para ello, se define un conjunto amplio y heterogéneo de configuraciones que abarca desde enfoques lineales clásicos —como Logistic Regression en sus variantes estándar, balanceada y con ajuste automático mediante LogisticRegressionCV— hasta modelos calibrados orientados a mejorar la calidad de las probabilidades estimadas, como RidgeClassifier y LinearSVC con calibración sigmoide.
Asimismo, se incorporan meta-estimadores no lineales de mayor capacidad expresiva, incluyendo Random Forest, Gradient Boosting, XGBoost con corrección explícita del desbalance de clases y un MLP neuronal de arquitectura simple.
Como alternativa adicional, se evalúa un meta-modelo basado en LightGBM, ajustado mediante el parámetro scale_pos_weight para gestionar el desbalance de forma interna.
Cada uno de estos algoritmos se integra como estimador final dentro del mismo stacking base, compuesto por CatBoost y LightGBM entrenados sin técnicas de muestreo. Este diseño permite evaluar de manera controlada y comparable el efecto específico de cada meta-estimador sobre la combinación de ambos modelos.
La experimentación se restringe al enfoque passthrough, dado que los análisis previos demostraron que evitar el muestreo externo produce resultados más estables y con mejor rendimiento global. En conjunto, esta exploración permite seleccionar el meta-estimador que maximiza la calidad del ensamblado final, sentando las bases para la fase posterior de ajuste fino y evaluación definitiva del modelo.
from sklearn.neural_network import MLPClassifier
def get_model_configs(random_state=42):
"""
Configuraciones de stacking y ensembles para probar
"""
#Metas
# 1. LogisticRegression (baseline)
LR_balanced = LogisticRegression(
solver='liblinear',
class_weight='balanced',
C=0.1,
max_iter=1000,
random_state=random_state
)
LR_simple = LogisticRegression(
solver='liblinear',
max_iter=1000,
random_state=random_state
)
# LogisticRegressionCV (auto-tune)
LR_CV_PR = LogisticRegressionCV(
cv=3,
scoring='average_precision', # PR-AUC focus
class_weight='balanced',
solver='liblinear',
max_iter=1000,
random_state=random_state,
n_jobs=-1
)
# Ridge calibrado (robusto a correlación)
Ridge_calib = CalibratedClassifierCV(
estimator=RidgeClassifier(alpha=1.0, class_weight='balanced'),
method='sigmoid', # o 'isotonic'
cv=3 # Calibration CV
)
# LinearSVC calibrado (márgenes grandes)
LinearSVC_calib = CalibratedClassifierCV(
LinearSVC(class_weight='balanced', max_iter=10000),
method='sigmoid',
cv=3
)
# RandomForest simple
RF_simple = RandomForestClassifier(
n_estimators=50,
max_depth=5, # Controlar complejidad
class_weight='balanced',
random_state=random_state,
n_jobs=-1
)
# LightGBM con scale_pos_weight
LGB_scale = LGBMClassifier(
scale_pos_weight=99, # 1% positivos → 99
n_estimators=50,
max_depth=3, # Conservador
random_state=random_state,
n_jobs=1, # Meta-modelo no necesita paralelismo
verbose=-1
)
# GradientBoosting (intermedio)
GB_simple = GradientBoostingClassifier(
n_estimators=50,
learning_rate=0.1,
max_depth=3,
random_state=random_state
)
# XGBoost con scale
XGB_scale = XGBClassifier(
scale_pos_weight=99,
n_estimators=50,
max_depth=3,
learning_rate=0.1,
random_state=random_state,
eval_metric='logloss'
)
MLP_simple = MLPClassifier(hidden_layer_sizes=(50,), max_iter=1000, random_state=42)
# Modelos Base
catboost_base = CatBoostClassifier(
random_state=random_state,
allow_writing_files=False, # ← EVITA crear directorios
train_dir=None # ← También importante
)
lgbm_base = LGBMClassifier(
random_state=random_state
)
# Stackings
stacking_cb_lgb_meta_lr_bal = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= LR_balanced,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_LR_simple = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= LR_simple,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_LR_CV_PR = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= LR_CV_PR,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_Ridge_calib = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= Ridge_calib,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_LinearSVC_calib = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= LinearSVC_calib,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_RF_simple = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= RF_simple,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_LGB_scale = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= LGB_scale,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_GB_simple = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= GB_simple,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_XGB_scale = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= XGB_scale,
cv=3,
n_jobs=-1
)
stacking_cb_lgb_meta_MLP_simple = StackingClassifier(
estimators=[
('catboost', catboost_base),
('lightgbm', lgbm_base)
],
final_estimator= MLP_simple,
cv=3,
n_jobs=-1
)
# -----------------------------------------------------
# RETORNAR TODAS LAS CONFIGURACIONES
# -----------------------------------------------------
return {
# Stackings con metas basicas
'Stacking_CatBoost_LightGBM_meta_LR_balanced': stacking_cb_lgb_meta_lr_bal,
'Stacking_CatBoost_LightGBM_meta_LR_simple': stacking_cb_lgb_meta_LR_simple,
'Stacking_CatBoost_LightGBM_meta_LR_cv': stacking_cb_lgb_meta_LR_CV_PR,
'Stacking_CatBoost_LightGBM_meta_Ridge': stacking_cb_lgb_meta_Ridge_calib,
'Stacking_CatBoost_LightGBM_meta_SVC': stacking_cb_lgb_meta_LinearSVC_calib,
'Stacking_CatBoost_LightGBM_meta_RF': stacking_cb_lgb_meta_RF_simple,
'Stacking_CatBoost_LightGBM_meta_LGB': stacking_cb_lgb_meta_LGB_scale,
'Stacking_CatBoost_LightGBM_meta_GB': stacking_cb_lgb_meta_GB_simple,
'Stacking_CatBoost_LightGBM_meta_XGB': stacking_cb_lgb_meta_XGB_scale,
'Stacking_CatBoost_LightGBM_meta_MLP': stacking_cb_lgb_meta_MLP_simple
}
# ============================================================================
# Re-Definicion de samplers
# ============================================================================
def get_sampler_configs(random_state=42):
return {
# Sin samplers
'Passthrough': 'passthrough',
}
file_path_results = 'results/samplers_fase4/summary_results.csv'
file_path = 'results/samplers_fase4'
#df_results = ejecutar_experimento_screening(df_preproc, 'es_cliente', file_path, 5, test_size=0.1)
file_path_results = 'results/samplers_fase4/summary_results.csv'
file_path = 'results/samplers_fase4'
visualizar_resultados_completos(file_path_results, metricas_clave=['F1_Score', 'Recall', 'Lift_Top10'])
| Model | Sampler | PR_AUC | ROC_AUC | F1_Score | Recall | Lift_Top10 | Gain_Top10 | Time_Sec | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Stacking_CatBoost_LightGBM_meta_LR_simple | Passthrough | 0.442600 | 0.908800 | 0.443300 | 0.300100 | 7.340000 | 0.733500 | 147.960000 |
| 1 | Stacking_CatBoost_LightGBM_meta_MLP | Passthrough | 0.442600 | 0.908900 | 0.440600 | 0.296200 | 7.350000 | 0.734600 | 136.100000 |
| 2 | Stacking_CatBoost_LightGBM_meta_SVC | Passthrough | 0.442400 | 0.908400 | 0.444000 | 0.302900 | 7.330000 | 0.732900 | 128.130000 |
| 3 | Stacking_CatBoost_LightGBM_meta_LR_balanced | Passthrough | 0.441900 | 0.913000 | 0.241500 | 0.603000 | 7.360000 | 0.735700 | 124.260000 |
| 4 | Stacking_CatBoost_LightGBM_meta_Ridge | Passthrough | 0.441600 | 0.907700 | 0.443700 | 0.302400 | 7.320000 | 0.731800 | 137.420000 |
| 5 | Stacking_CatBoost_LightGBM_meta_LR_cv | Passthrough | 0.441500 | 0.912300 | 0.198000 | 0.655100 | 7.350000 | 0.735200 | 150.220000 |
| 6 | Stacking_CatBoost_LightGBM_meta_RF | Passthrough | 0.427400 | 0.913300 | 0.098700 | 0.795600 | 7.350000 | 0.734600 | 121.420000 |
| 7 | Stacking_CatBoost_LightGBM_meta_LGB | Passthrough | 0.423000 | 0.910300 | 0.091700 | 0.813500 | 7.310000 | 0.730700 | 121.120000 |
| 8 | Stacking_CatBoost_LightGBM_meta_XGB | Passthrough | 0.411700 | 0.913500 | 0.095000 | 0.808500 | 7.350000 | 0.735200 | 128.210000 |
| 9 | Stacking_CatBoost_LightGBM_meta_GB | Passthrough | 0.398300 | 0.913300 | 0.417500 | 0.281100 | 7.390000 | 0.739100 | 126.610000 |
analizar_top_5(df_preproc, 'es_cliente', 'results/samplers_fase4')
================================================================================
ANÁLISIS DETALLADO TOP 3 MODELOS - EVALUACIÓN FINAL EN Conjunto de Test (Hold-out)
================================================================================
> Evaluando: #1 Stacking_CatBoost_LightGBM_meta_LR_simple (Passthrough)
Tiempo de entrenamiento final: 39.01 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4397 (Métrica de Ref.)
ROC-AUC: 0.9179
Lift@10%: 7.38
Gain@10%: 0.7374
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.88 0.26 0.40 198
accuracy 0.99 19517
macro avg 0.94 0.63 0.70 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #2 Stacking_CatBoost_LightGBM_meta_MLP (Passthrough)
Tiempo de entrenamiento final: 48.17 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4419 (Métrica de Ref.)
ROC-AUC: 0.9182
Lift@10%: 7.43
Gain@10%: 0.7424
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.94 0.26 0.40 198
accuracy 0.99 19517
macro avg 0.97 0.63 0.70 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #3 Stacking_CatBoost_LightGBM_meta_SVC (Passthrough)
Tiempo de entrenamiento final: 32.14 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4400 (Métrica de Ref.)
ROC-AUC: 0.9182
Lift@10%: 7.38
Gain@10%: 0.7374
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.88 0.27 0.41 198
accuracy 0.99 19517
macro avg 0.94 0.63 0.70 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------
> Evaluando: #4 Stacking_CatBoost_LightGBM_meta_LR_balanced (Passthrough)
Tiempo de entrenamiento final: 32.85 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4379 (Métrica de Ref.)
ROC-AUC: 0.9183
Lift@10%: 7.43
Gain@10%: 0.7424
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 1.00 0.96 0.98 19319
1 0.15 0.62 0.24 198
accuracy 0.96 19517
macro avg 0.57 0.79 0.61 19517
weighted avg 0.99 0.96 0.97 19517
------------------------------------------------------------
> Evaluando: #5 Stacking_CatBoost_LightGBM_meta_Ridge (Passthrough)
Tiempo de entrenamiento final: 35.20 s
**Métricas Finales (Conjunto de Test):**
PR-AUC: 0.4394 (Métrica de Ref.)
ROC-AUC: 0.9160
Lift@10%: 7.33
Gain@10%: 0.7323
Reporte de Clasificación (Threshold 0.5):
precision recall f1-score support
0 0.99 1.00 1.00 19319
1 0.91 0.27 0.41 198
accuracy 0.99 19517
macro avg 0.95 0.63 0.71 19517
weighted avg 0.99 0.99 0.99 19517
------------------------------------------------------------ Generando Curvas de Negocio Comparativas (Gain & Lift) en el Conjunto de Test...
Análisis completado.
📌 Conclusiones
Los resultados obtenidos muestran que el meta-estimador basado en MLP alcanza el mejor desempeño global en términos de métricas agregadas. No obstante, su nivel de recall sensiblemente inferior supone una limitación relevante en el contexto de este trabajo, donde el objetivo principal es maximizar la identificación de usuarios con potencial de compra dentro de un dataset altamente desbalanceado.
Desde una perspectiva aplicada al negocio, la pérdida de recall implica dejar de detectar una proporción significativa de clientes reales, lo que reduce el impacto operativo del modelo.
Por este motivo, y priorizando un equilibrio adecuado entre capacidad discriminativa, sensibilidad y estabilidad, se selecciona finalmente Logistic Regression con class_weight='balanced' como meta-estimador óptimo para el esquema de stacking.
Esta elección se justifica no solo por su buen rendimiento global, sino también por su robustez, interpretabilidad y su comportamiento consistente entre validación y conjunto de prueba, características especialmente relevantes en un entorno académico y aplicado.
🏆 Modelo final seleccionado:
Stacking CatBoost + LightGBM con meta-estimador Logistic Regression (balanced)
- PR-AUC: 0.4379
- Recall: 0.62
- F1-Score: 0.24
- Lift@10%: 7.43
#!pip install optuna
Fase II - Ciclos iterativos de optimización
Ciclo 1 - Optimización de Hiperparámetros
La optimización de hiperparámetros constituye una etapa clave para maximizar el rendimiento de los modelos empleados y garantizar un equilibrio adecuado entre capacidad predictiva y generalización. En problemas de clasificación altamente desbalanceados, como el abordado en este trabajo, una optimización inadecuada puede derivar fácilmente en fenómenos de overfitting o underfitting, afectando de forma directa a métricas críticas como el recall y la PR-AUC
Estrategia y flujo de trabajo
La optimización se implementó siguiendo un enfoque jerárquico y coherente con la arquitectura de stacking seleccionada:
-
Optimización individual de modelos base (Nivel 1):
Los modelos LightGBM y CatBoost se optimizaron de forma independiente sobre el conjunto de entrenamiento-validación (
X_train_val), ajustando sus hiperparámetros clave para maximizar el rendimiento individual. -
Generación de predicciones Out-of-Fold (OOF):
Mediante una estrategia de K-Fold Cross-Validation con 5 particiones, cada modelo base generó predicciones sobre subconjuntos de datos no utilizados durante su entrenamiento. Estas predicciones OOF, libres de data leakage, se concatenaron para formar el conjunto de características del Nivel 2, denotado como
X_level2. -
Optimización del meta-estimador (Nivel 2):
El meta-modelo, basado en Regresión Logística, se optimizó utilizando
X_level2como entrada y las etiquetas reales (y_train_val) como variable objetivo, asegurando coherencia con la estructura del ensamblado.
Metodología de búsqueda: Optuna
Para la búsqueda de hiperparámetros se empleó la librería Optuna, una herramienta de optimización moderna basada en Optimización Bayesiana mediante el método Tree-structured Parzen Estimator (TPE), junto con mecanismos automáticos de poda temprana de configuraciones poco prometedoras (Akiba et al., 2019).
Este enfoque resulta más eficiente que métodos tradicionales como la búsqueda en rejilla o la búsqueda aleatoria, ya que utiliza el historial de evaluaciones previas para explorar de forma adaptativa el espacio de hiperparámetros, reduciendo significativamente el coste computacional y acelerando la convergencia hacia soluciones óptimas.
Además, Optuna permite la persistencia de los estudios mediante almacenamiento externo, lo que facilita la reanudación de experimentos, la comparación sistemática de resultados y el análisis posterior de las configuraciones más relevantes.
En todas las fases de optimización, el objetivo principal fue maximizar la métrica Área Bajo la Curva Precisión-Recuperación (PR-AUC), al tratarse de una métrica más informativa que la ROC-AUC en escenarios de desbalance extremo y alineada con los objetivos de negocio del problema planteado.
import optuna
from optuna.pruners import MedianPruner
from optuna.samplers import TPESampler
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from sklearn.metrics import precision_recall_curve, auc
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
from catboost import CatBoostError
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import RobustScaler
from sklearn.base import clone
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.under_sampling import TomekLinks
import pandas as pd
import numpy as np
import os
import json
# --- FUNCIONES DE UTILIDAD PARA PERSISTENCIA ---
def save_params_to_json(params, filename, folder='results/tuning/Defaultparams'):
"""Guarda un diccionario de parámetros en un archivo JSON."""
os.makedirs(folder, exist_ok=True)
filepath = os.path.join(folder, f"{filename}.json")
with open(filepath, 'w') as f:
json.dump(params, f, indent=4)
return filepath
def load_params_from_json(filename, folder='results/tuning/Defaultparams'):
"""Carga un diccionario de parámetros de un archivo JSON si existe."""
filepath = os.path.join(folder, f"{filename}.json")
if os.path.exists(filepath):
with open(filepath, 'r') as f:
print(f"✔️ Cargando parámetros desde {filepath}")
return json.load(f)
return None
# ==============================================================================
# FASE 1: OPTIMIZACIÓN INDIVIDUAL DE MODELOS BASE (CON CARGA/GUARDADO)
# ==============================================================================
def get_params_and_model(model_name, trial, categorical_features=None, random_state=42):
if model_name == 'LGBM':
params = {
'n_estimators': trial.suggest_int('n_estimators', 200, 1500, step=100),
'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.1, log=True),
'num_leaves': trial.suggest_int('num_leaves', 10, 100),
'max_depth': trial.suggest_int('max_depth', 3, 12),
'min_child_samples': trial.suggest_int('min_child_samples', 10, 80),
'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 10.0, log=True),
'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True),
}
model = LGBMClassifier(random_state=random_state, n_jobs=-1, verbose=-1, **params)
elif model_name == 'CatBoost':
params = {
'iterations': trial.suggest_int('iterations', 200, 1500, step=100),
'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.1, log=True),
'depth': trial.suggest_int('depth', 3, 10),
'l2_leaf_reg': trial.suggest_float('l2_leaf_reg', 1e-4, 10.0, log=True),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'min_data_in_leaf': trial.suggest_int('min_data_in_leaf', 5, 50)
}
model = CatBoostClassifier(
random_state=random_state, verbose=0, thread_count=-1, allow_writing_files=False, train_dir=None,
**params
)
elif model_name == 'LR_META':
print("🛠️ Optuna: Configurando Logistic Regression (Meta)...")
lr_params = {
'C': trial.suggest_float('lr_C', 0.001, 0.1, log=True),
'penalty': trial.suggest_categorical('lr_penalty', ['l1', 'l2']),
}
params = lr_params.copy()
# Crear el modelo de Logistic Regression
model = LogisticRegression(class_weight='balanced', **lr_params)
else:
raise ValueError("Modelo no soportado.")
return params, model
def objective_base_model(trial, X, y, model_name, categorical_features, random_state=42):
"""
Función objetivo de Optuna para optimizar un modelo base individual.
MODIFICADA para CatBoost para evitar cross_val_predict y joblib.
"""
# Validar inputs
if model_name not in ['LGBM', 'CatBoost']:
raise ValueError(f"Modelo {model_name} no soportado")
params, model = get_params_and_model(model_name, trial, categorical_features)
# 2. Evaluación (Cross-Validation)
cv_strategy = StratifiedKFold(n_splits=5, shuffle=True, random_state=random_state)
y_pred_proba = np.zeros(len(y))
if model_name in ['LGBM', 'CatBoost']:
# Implementación manual del K-FOLD CV usando el modelo ya configurado
for fold, (train_idx, val_idx) in enumerate(cv_strategy.split(X, y)):
X_train_fold, X_val_fold = X.iloc[train_idx], X.iloc[val_idx]
y_train_fold, y_val_fold = y.iloc[train_idx], y.iloc[val_idx]
# Clonar el modelo ya creado (más eficiente que reconstruirlo)
model_fold = clone(model)
try:
model_fold.fit(X_train_fold, y_train_fold)
except Exception: # Captura cualquier error (incluye CatBoostError)
print(f"Error en {model_name} fold {fold}: {e}")
raise optuna.TrialPruned()
# Predecir OOF
y_pred_proba[val_idx] = model_fold.predict_proba(X_val_fold)[:, 1]
# 3. Cálculo del PR-AUC
precision, recall, _ = precision_recall_curve(y, y_pred_proba)
pr_auc = auc(recall, precision)
return pr_auc
def run_base_tuning(X_train_val, y_train_val, model_name, categorical_features=None, folder_name="results/tuning/Defaultparams", n_trials=50, random_state=42):
"""Ejecuta la optimización de Optuna para un modelo base con persistencia."""
filename = f"{model_name.lower()}_best_params"
# 1. INTENTAR CARGAR RESULTADOS PREVIOS
best_params = load_params_from_json(filename, folder_name)
if best_params:
print(f"🎉 Resultados cargados para {model_name}. No se requiere re-optimizar.")
# Como no tenemos el score, devolvemos un valor dummy alto
return best_params, 0.5
# 2. OPTIMIZAR
print(f"\nIniciando Optuna para {model_name}...")
# 1. Definición del Pruner (Estrategia de poda)
# Detiene los trials que parecen malos al inicio
pruner = MedianPruner(
n_startup_trials=10, # No podar los primeros 10 trials (para tener una base robusta)
n_warmup_steps=10 # No podar hasta que se hayan completado 10 pasos (si usas CV, serán los primeros 2 folds)
)
# 2. Definición del Sampler (Estrategia de muestreo)
# TPE es el muestreador bayesiano por defecto y el más eficiente
# Usamos una semilla (seed) para asegurar la reproducibilidad de la búsqueda
sampler = TPESampler(seed=42)
study = optuna.create_study(
direction="maximize", # Queremos maximizar la métrica (ej. PR-AUC)
study_name=f'{model_name}_Tuning_PR_AUC',
sampler=sampler,
pruner=pruner
)
study.optimize(
lambda trial: objective_base_model(trial, X_train_val, y_train_val, model_name, categorical_features, random_state),
n_trials=n_trials,
show_progress_bar=True
)
# 3. GUARDAR RESULTADOS
best_params = study.best_params
best_value = study.best_value
save_params_to_json(best_params, filename, folder_name)
print(f"✅ {model_name} Optimización Completada. Mejor PR-AUC: {best_value:.4f}")
return best_params, best_value
# ==============================================================================
# FASE 2: GENERACIÓN DE FEATURES DE NIVEL 2 (CON PERSISTENCIA)
# ==============================================================================
def generate_oof_predictions(X, y, best_params_lgbm, best_params_cb, categorical_features = None, folder_name="results/oof_features_default", random_state=42):
os.makedirs(folder_name, exist_ok=True)
filepath = os.path.join(folder_name, 'X_level2_oof.csv')
# 1. INTENTAR CARGAR RESULTADOS PREVIOS
if os.path.exists(filepath):
print(f"✅ Resultados previos de OOF encontrados en {filepath}.")
print("Cargando y devolviendo resultados existentes...")
try:
# Intentamos cargar el archivo CSV guardado
X_level2 = pd.read_csv(filepath, index_col=X.index.name or None)
# Verificación rápida: debe tener el mismo número de filas que X
if len(X_level2) == len(X):
return X_level2
else:
print("⚠️ Archivo de OOF encontrado, pero el número de filas no coincide. ¡Regenerando!")
# Si las filas no coinciden, se salta el 'return' y el código sigue para regenerar
except Exception as e:
print(f"⚠️ Error al cargar el archivo de OOF: {e}. ¡Regenerando!")
# Si hay un error de lectura, el código sigue para regenerar
# 1. Definición de Modelos Base Finales (fijos)
lgbm_params = best_params_lgbm.copy()
cb_params = best_params_cb.copy()
cb_params.update({'eval_metric':'PRAUC','cat_features': categorical_features,'verbose': 0, 'allow_writing_files': False, 'train_dir': None, 'thread_count': -1})
lgbm_params.update({'eval_metric':'average-precision', 'categorical_feature': categorical_features,'verbose': -1, 'n_jobs': -1})
print(f"Catboost params:{cb_params}")
print(f"Catboost params:{lgbm_params}")
lgbm_final = LGBMClassifier(random_state=random_state, **lgbm_params)
cb_final = CatBoostClassifier(random_state=random_state, **cb_params)
cv_strategy = StratifiedKFold(n_splits=5, shuffle=True, random_state=random_state)
X_level2 = pd.DataFrame(index=X.index)
models = {
'LGBM': lgbm_final,
'CatBoost': cb_final
}
# 2. Generar Predicciones OOF
print("Generando predicciones OOF...")
for name, model in models.items():
print(f" -> Generando OOF para: {name.upper()}...")
y_oof_proba = cross_val_predict(
estimator=model,
X=X,
y=y,
cv=cv_strategy,
method='predict_proba',
n_jobs=-1 # Usamos todos los núcleos disponibles
)[:, 1]
X_level2[f'oof_pred_{name}'] = y_oof_proba
X_level2.to_csv(filepath, index=True)
print(f"✅ Features de Nivel 2 generadas y guardadas en {filepath}")
return X_level2
# ==============================================================================
# FASE 3: OPTIMIZACIÓN DEL META-ESTIMADOR (CON CARGA/GUARDADO)
# ==============================================================================
def objective_meta_estimator(trial, X_level2, y, random_state=42):
"""
Versión simplificada que solo usa get_params_and_model para el meta-estimador.
SIN class_weight='balanced'
"""
# 1. Usar nuestra función unificada
lr_params, meta_model = get_params_and_model(
model_name='LR_META',
trial=trial,
random_state=random_state
)
print(f"Meta model params: {meta_model.get_params()}")
# 3. Pipeline
pipeline = ImbPipeline(steps=[
('classifier', meta_model)
])
# 4. Cross-validation (5-fold como en tu ejemplo)
cv_strategy = StratifiedKFold(n_splits=5, shuffle=True, random_state=random_state)
# 5. Predecir con validación cruzada
y_pred_proba = cross_val_predict(
pipeline,
X_level2,
y,
cv=cv_strategy,
method='predict_proba',
n_jobs=-1
)[:, 1]
# 6. Calcular PR-AUC
precision, recall, _ = precision_recall_curve(y, y_pred_proba)
pr_auc = auc(recall, precision)
return pr_auc
def run_meta_tuning(X_level2, y_train_val, n_trials=20, folder_name="results/tuning/Defaultparams", random_state=42):
"""Ejecuta la optimización del Meta-Estimador con persistencia."""
filename = 'lr_meta_best_params'
# 1. INTENTAR CARGAR RESULTADOS PREVIOS
best_params = load_params_from_json(filename, folder_name)
if best_params:
print("🎉 Resultados cargados para Logistic Regression (Meta-Estimador).")
return best_params, None
# 2. OPTIMIZAR
print("\nIniciando Optuna para Meta-Estimador (Logistic Regression)...")
study_meta = optuna.create_study(direction='maximize', study_name='Meta_Estimator_Tuning_PR_AUC')
study_meta.optimize(
lambda trial: objective_meta_estimator(trial, X_level2, y_train_val, random_state),
n_trials=n_trials
)
# 3. GUARDAR RESULTADOS
best_params = study_meta.best_params
best_value = study_meta.best_value
save_params_to_json(best_params, filename, folder_name)
print(f"✅ Meta-Estimador Optimización Completada. Mejor PR-AUC: {best_value:.4f}")
return best_params, best_value
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_preproc)
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada.
# Generar (o cargar) Features de Nivel 1
lgbm_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='LGBM', folder_name="results/tuning/params_fase1", n_trials=50)
cb_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='CatBoost', folder_name="results/tuning/params_fase1", n_trials=50)
# Generar (o cargar) Features de Nivel 2
X_level2 = generate_oof_predictions(X_train_val, y_train_val, lgbm_best_params, cb_best_params, folder_name="results/tuning/oof_features_fase1")
# 3. Optimizar (o cargar) el Meta-Estimador
lr_best_params, lr_best_score = run_meta_tuning(X_level2, y_train_val, folder_name="results/tuning/params_fase1", n_trials=100)
print("\n--- RESUMEN DE MEJORES PARÁMETROS ---")
print(f"LGBM: {lgbm_best_params}")
print(f"CatBoost: {cb_best_params}")
print(f"LR Meta: {lr_best_params}")
✔️ Cargando parámetros desde results/tuning/params_fase1\lgbm_best_params.json
🎉 Resultados cargados para LGBM. No se requiere re-optimizar.
✔️ Cargando parámetros desde results/tuning/params_fase1\catboost_best_params.json
🎉 Resultados cargados para CatBoost. No se requiere re-optimizar.
✅ Resultados previos de OOF encontrados en results/tuning/oof_features_fase1\X_level2_oof.csv.
Cargando y devolviendo resultados existentes...
✔️ Cargando parámetros desde results/tuning/params_fase1\lr_meta_best_params.json
🎉 Resultados cargados para Logistic Regression (Meta-Estimador).
--- RESUMEN DE MEJORES PARÁMETROS ---
LGBM: {'n_estimators': 1200, 'learning_rate': 0.031483861784621175, 'num_leaves': 10, 'max_depth': 8, 'min_child_samples': 56, 'reg_alpha': 0.001034762180977269, 'reg_lambda': 0.002758894735443576}
CatBoost: {'iterations': 1200, 'learning_rate': 0.010043418192921075, 'depth': 8, 'l2_leaf_reg': 1.4123243282572246, 'subsample': 0.6113633312351796, 'min_data_in_leaf': 25}
LR Meta: {'lr_C': 0.010045081076809034, 'lr_penalty': 'l1'}
📌 Conclusiones de la optimización
La fase de optimización permitió identificar una configuración de hiperparámetros coherente y estable para el clasificador final basado en stacking. A continuación, se resumen los valores óptimos obtenidos y se interpreta su papel dentro de la arquitectura del modelo.
Parámetros óptimos seleccionados
| Modelo | Hiperparámetro | Valor | Interpretación |
|---|---|---|---|
| LightGBM | n_estimators |
1200 | Un número elevado de árboles, combinado con una tasa de aprendizaje baja, sugiere un modelo entrenado de forma progresiva e incremental, favoreciendo la estabilidad predictiva. |
learning_rate |
0.0315 | Una tasa de aprendizaje reducida permite una convergencia controlada, mejorando la precisión y la capacidad de generalización. | |
num_leaves |
10 | Un número de hojas muy limitado actúa como un mecanismo de regularización estructural fuerte, restringiendo la complejidad de cada árbol. | |
| CatBoost | iterations |
1200 | Refleja una estrategia de entrenamiento prolongado alineada con tasas de aprendizaje bajas. |
learning_rate |
0.0100 | Tasa de aprendizaje muy baja, que favorece una convergencia gradual y robusta frente al ruido. | |
depth |
8 | Profundidad moderada que equilibra capacidad expresiva y generalización. | |
l2_leaf_reg |
1.412 | Penalización L2 moderada que contribuye a suavizar las predicciones y evitar pesos extremos. | |
| Meta-estimador (LR) | C |
0.0100 | Un valor de C muy bajo implica una regularización intensa, reduciendo el riesgo de sobreajuste en el nivel meta. |
penalty |
L1 | La penalización L1 favorece la selección automática de predictores, anulando coeficientes poco informativos del nivel 1. |
Interpretación global del stacking
Los resultados reflejan una estrategia consistente orientada a la robustez y la regularización en todas las capas del modelo:
- Modelos base (Nivel 1): Tanto LightGBM como CatBoost fueron optimizados con tasas de aprendizaje muy bajas, lo que indica que el sistema se beneficia de la agregación de múltiples modelos débiles entrenados de forma incremental. Esta configuración reduce la varianza, mitiga la sensibilidad al ruido y mejora la estabilidad global.
- Meta-estimador (Nivel 2): La combinación de penalización L1 y una regularización intensa apunta a un meta-modelo deliberadamente parsimonioso, cuyo objetivo es seleccionar únicamente las señales más consistentes provenientes de las predicciones out-of-fold, descartando aquellas redundantes o inestables.
En conjunto, el sistema final integra la diversidad y capacidad predictiva de dos modelos de gradient boosting fuertemente regularizados con un meta-estimador sencillo pero estricto. Esta arquitectura permite extraer únicamente la señal más fiable, maximizando la capacidad de generalización y manteniendo un comportamiento robusto en un contexto de desbalance extremo.
Evaluación del modelo optimizado sobre el conjunto de test
El siguiente fragmento de código implementa un pipeline completo de validación final para el modelo de stacking seleccionado, basado en CatBoost y LightGBM como modelos base y una Regresión Logística balanceada como meta-estimador. Esta fase tiene como objetivo evaluar de forma rigurosa el comportamiento del modelo optimizado sobre el conjunto de test independiente, garantizando una estimación realista de su capacidad de generalización.
-
Se incorpora una carga condicional del modelo entrenado mediante
joblib, evitando reentrenamientos innecesarios y asegurando reproducibilidad y eficiencia computacional. -
El proceso contempla la reconstrucción explícita de los clasificadores base (CatBoost y LightGBM) con los hiperparámetros optimizados y sin técnicas de muestreo, seguida de su integración en un
StackingClassifiercon validación cruzada interna. - El meta-estimador seleccionado es una Regresión Logística con ponderación balanceada, que permite corregir parcialmente el desbalance de clases sin introducir datos sintéticos ni alterar la distribución original.
- Se implementa un módulo de evaluación exhaustiva que calcula tanto métricas clásicas de clasificación (Accuracy, Precision, Recall, F1, ROC-AUC y PR-AUC) como métricas orientadas al negocio, incluyendo Gain, Lift y tasa de captura en los primeros deciles.
-
El análisis se completa con representaciones gráficas clave:
- Curvas ROC y Precisión–Recall.
- Matriz de confusión sobre el conjunto de test.
- Tabla estructurada de Lift y Gain por deciles.
-
Adicionalmente, se incluyen herramientas de interpretabilidad post-hoc, como:
- La inspección de los coeficientes del meta-estimador, que permite analizar el peso relativo de cada modelo base.
- La revisión agregada de la importancia de variables en el nivel 1.
- Finalmente, se genera un archivo con las predicciones del conjunto de test, garantizando trazabilidad y facilitando análisis posteriores y posibles integraciones operativas.
En conjunto, este módulo consolida la evaluación final del sistema propuesto, proporcionando una visión integral del rendimiento predictivo, la estabilidad del ensamblado y su adecuación a escenarios reales caracterizados por un fuerte desbalance de clases.
import joblib
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
from sklearn.base import clone
from sklearn.ensemble import StackingClassifier
from sklearn.linear_model import LogisticRegression
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.under_sampling import TomekLinks
from sklearn.preprocessing import RobustScaler
from sklearn.compose import ColumnTransformer
from sklearn.metrics import (
precision_score, recall_score, f1_score, accuracy_score,
roc_auc_score, average_precision_score, confusion_matrix,
roc_curve, precision_recall_curve, ConfusionMatrixDisplay
)
# --- Función de utilidad para cargar (debe estar al inicio del script) ---
def load_model_from_joblib(model_path):
"""Carga un modelo de joblib si existe, devuelve None si no."""
if os.path.exists(model_path):
print(f"✔️ Modelo encontrado. Cargando desde: {model_path}")
return joblib.load(model_path)
return None
def construir_y_predecir_modelo_final(X_train_val, y_train_val, X_test, y_test, lgbm_params, cb_params, lr_meta_params, categorical_features = None, output_folder='results/final_model', random_state=42):
os.makedirs(output_folder, exist_ok=True)
model_path = os.path.join(output_folder, 'stacking_final_pipeline.joblib')
# 1. Carga Condicional: Intentar cargar el modelo ya entrenado
final_model = load_model_from_joblib(model_path)
if final_model is None:
# --- 🛠️ CONSTRUCCIÓN Y ENTRENAMIENTO (Solo si no existe) ---
print("--- 🛠️ Construyendo el Modelo Stacking Final ---")
# 1. Definición de Estimadores Base (Sin balanceo)
lgbm_final = LGBMClassifier(random_state=random_state, eval_metric = 'average-precision', categorical_feature=categorical_features, n_jobs=-1, verbose=-1, **lgbm_params)
cb_final = CatBoostClassifier(random_state=random_state, eval_metric ='PRAUC', cat_features= categorical_features, verbose=0, thread_count=-1, allow_writing_files=False, train_dir=None, **cb_params)
estimators_base = [
('lgbm', lgbm_final),
('cb', cb_final)
]
# 2. Definición del Meta-Estimador
meta_C = lr_meta_params.get('lr_C', lr_meta_params.get('C', 1.0))
meta_penalty = lr_meta_params.get('lr_penalty', lr_meta_params.get('penalty', 'l2'))
solver = 'liblinear' if meta_penalty == 'l1' else 'lbfgs'
meta_estimator_final = LogisticRegression(
solver=solver,
random_state=random_state,
class_weight='balanced', # Balanceo solo en el meta-estimador
C=meta_C,
penalty=meta_penalty,
max_iter=1000
)
cv_strategy = StratifiedKFold(
n_splits=5,# Mismo CV que en OOF para consistencia
shuffle=True,
random_state=random_state
)
# 3. Stacking Classifier (Sin ImbPipeline)
final_model = StackingClassifier(
estimators=estimators_base,
final_estimator=meta_estimator_final,
cv=cv_strategy,
n_jobs=-1,
verbose=2 # Reducida la verbosidad
)
# 4. Entrenamiento
print(f"\nIniciando entrenamiento final en {len(X_train_val)} muestras...")
final_model.fit(X_train_val, y_train_val)
print("✅ Entrenamiento completado exitosamente.")
# 5. Guardado
print(f"\n💾 Guardando modelo final en: {model_path}")
joblib.dump(final_model, model_path)
else:
print("✅ Usando modelo previamente entrenado.")
# --- 6. Predicción en Test (Común a ambos caminos) ---
print("\nGenerando predicciones en X_test...")
y_pred = final_model.predict(X_test)
y_pred_proba = final_model.predict_proba(X_test)[:, 1]
# Guardar predicciones también es buena práctica
preds_df = pd.DataFrame({'y_true': y_test, 'y_pred': y_pred, 'y_prob': y_pred_proba})
preds_df.to_csv(os.path.join(output_folder, 'test_predictions.csv'), index=False)
return y_test, y_pred, y_pred_proba, final_model
# --- 2. FUNCIÓN DE EVALUACIÓN (SIN CAMBIOS MAYORES) ---
def evaluar_modelo(y_test, y_pred, y_pred_proba):
print("\n--- 📊 2.1. Métricas de Rendimiento Estándar ---")
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
metrics = {
'Accuracy': accuracy_score(y_test, y_pred),
'Precision': precision_score(y_test, y_pred),
'Recall': recall_score(y_test, y_pred),
'F1-Score': f1_score(y_test, y_pred),
'ROC AUC': roc_auc_score(y_test, y_pred_proba),
'PR AUC': average_precision_score(y_test, y_pred_proba),
'FP Rate': fp / (fp + tn),
}
# Imprimir bonito
print(pd.DataFrame(metrics.items(), columns=['Métrica', 'Valor']).round(4).to_markdown(index=False))
print("\n--- 💰 2.2. Métricas de Negocio ---")
business_metrics = {
'Total Test': len(y_test),
'Clientes Reales (1)': tp + fn,
'Detectados (TP)': tp,
'Perdidos (FN)': fn,
'Falsas Alarmas (FP)': fp,
'Tasa de Captura': f"{recall_score(y_test, y_pred):.2%}"
}
print(pd.DataFrame(business_metrics.items(), columns=['KPI', 'Valor']).to_markdown(index=False))
print("\n--- 💰 2.3. Curvas de Negocio (Lift y Gain) ---")
# Calcular la tabla de Lift y Gain
df_lift_gain = calcular_lift_gain(y_test, y_pred_proba)
print(df_lift_gain.to_markdown(index=False))
print("\n**Interpretación de Puntos Clave:**")
# Puntos Clave de la curva Gain
gain_90 = df_lift_gain.iloc[8]['Gain_Acumulado']
gain_10 = df_lift_gain.iloc[0]['Gain_Acumulado']
print(f"* 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **{gain_10}** de todos los clientes reales.")
# Punto Clave de la curva Lift
lift_10 = df_lift_gain.iloc[0]['Lift_Acumulado']
print(f"* 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **{lift_10}** en el primer decil significa que la tasa de conversión en ese grupo es **{lift_10} veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).")
# Gráficas
plt.figure(figsize=(18, 5))
# ROC
plt.subplot(1, 3, 1)
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
plt.plot(fpr, tpr, label=f'ROC-AUC={metrics["ROC AUC"]:.3f}', color='darkorange')
plt.plot([0, 1], [0, 1], 'k--')
plt.title('Curva ROC')
plt.legend()
# PR Curve
plt.subplot(1, 3, 2)
prec, rec, _ = precision_recall_curve(y_test, y_pred_proba)
plt.plot(rec, prec, label=f'PR-AUC={metrics["PR AUC"]:.3f}', color='green')
plt.title('Curva Precision-Recall')
plt.legend()
# Matriz Confusión
plt.subplot(1, 3, 3)
cm_display = ConfusionMatrixDisplay(confusion_matrix=confusion_matrix(y_test, y_pred))
cm_display.plot(ax=plt.gca(), cmap=plt.cm.Blues, values_format='d')
plt.title('Matriz de Confusión')
plt.tight_layout()
plt.show()
# --- 3. FUNCIONES DE ANÁLISIS (CORREGIDAS) ---
def analizar_meta_estimator(final_pipeline):
"""
Analiza y visualiza los coeficientes del Meta-Estimador (Logistic Regression)
para entender la ponderación de las predicciones de Nivel 1.
"""
print("\n--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador ---")
try:
# 🟢 CORRECCIÓN CLAVE: final_pipeline ahora es directamente el StackingClassifier
stacking_clf = final_pipeline
meta_est = stacking_clf.final_estimator_
# 1. Extracción de Coeficientes
coefs = meta_est.coef_[0]
feature_names = list(stacking_clf.named_estimators_.keys()) # ['lgbm', 'cb']
df_coefs = pd.DataFrame({
'Modelo Base': feature_names,
'Peso (Coef)': coefs,
'Peso Absoluto': np.abs(coefs)
}).sort_values('Peso Absoluto', ascending=False)
# 2. Análisis e Impresión de la Tabla
print("El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base:")
print(df_coefs.to_markdown(index=False))
# 4. Visualización
plt.figure(figsize=(8, 5))
plt.barh(df_coefs['Modelo Base'], df_coefs['Peso (Coef)'], color=['green' if c > 0 else 'red' for c in df_coefs['Peso (Coef)']])
plt.axvline(0, color='gray', linestyle='--')
plt.xlabel('Peso (Coeficiente) en Regresión Logística')
plt.title('Pesos de Modelos Base en el Meta-Estimador')
plt.gca().invert_yaxis()
plt.show()
except AttributeError as e:
print(f"⚠️ No se pudieron extraer coeficientes: {e}")
print("Es posible que el modelo no se haya entrenado correctamente o que el meta-estimador no sea lineal (como LogisticRegression).")
except Exception as e:
print(f"⚠️ Error inesperado al analizar el meta-estimador: {e}")
def calcular_lift_gain(y_true, y_prob):
"""Calcula Lift y Gain por deciles para curvas de negocio."""
# Crear DataFrame con probabilidades y etiquetas reales
df = pd.DataFrame({'y_true': y_true, 'y_prob': y_prob})
# Ordenar por probabilidad descendente
df = df.sort_values(by='y_prob', ascending=False).reset_index(drop=True)
# Calcular Deciles
df['Decil'] = pd.qcut(df.index, q=10, labels=False, duplicates='drop')
# Calcular métricas por decil
# Tasa base: Proporción de positivos en la población total
base_rate = df['y_true'].mean()
decile_summary = df.groupby('Decil').agg(
Total_Clientes=('y_true', 'sum'),
Total_Poblacion=('y_true', 'size')
).reset_index()
# Acumulativos
decile_summary['Total_Acumulado'] = decile_summary['Total_Poblacion'].cumsum()
decile_summary['Clientes_Acumulados'] = decile_summary['Total_Clientes'].cumsum()
# Métricas clave
total_positives = df['y_true'].sum()
total_population = len(df)
# Gain (Ganancia): % de Clientes (positivos) encontrados
decile_summary['Gain_Acumulado'] = decile_summary['Clientes_Acumulados'] / total_positives
# % Población
decile_summary['%_Poblacion'] = decile_summary['Total_Acumulado'] / total_population
# Lift (Elevación)
decile_summary['Lift_Acumulado'] = decile_summary['Gain_Acumulado'] / decile_summary['%_Poblacion']
# Filtrar solo la tabla final para el display
final_df = decile_summary[['Decil', '%_Poblacion', 'Clientes_Acumulados', 'Gain_Acumulado', 'Lift_Acumulado']].copy()
# Formateo
final_df['Decil'] = (final_df['Decil'] + 1).astype(str)
final_df['%_Poblacion'] = (final_df['%_Poblacion']).map('{:.0%}'.format)
final_df['Gain_Acumulado'] = (final_df['Gain_Acumulado']).map('{:.1%}'.format)
final_df['Lift_Acumulado'] = final_df['Lift_Acumulado'].round(2)
# Añadir columna de Tasa Base para referencia
final_df['Tasa_Base'] = base_rate.round(4)
return final_df
def analizar_importancia_base(final_pipeline, feature_names):
"""Analiza la importancia de variables en los modelos base (TODAS las variables)."""
print("\n--- 🌳 3.2. Importancia de Variables (Nivel 1) ---")
try:
stacking_clf = final_pipeline
# Extraer importancias
lgbm = stacking_clf.named_estimators_['lgbm']
try:
# Intenta el alias corto, que es el que te daba problemas
cb = stacking_clf.named_estimators_['cb']
print("INFO: Estimador CatBoost encontrado usando el alias 'cb'.")
except KeyError:
# Si el alias falla, prueba el nombre completo
cb = stacking_clf.named_estimators_['catboost']
print("INFO: Estimador CatBoost encontrado usando el nombre 'catboost'.")
imp_df = pd.DataFrame({
'Feature': feature_names,
'LGBM_Imp': lgbm.feature_importances_,
'CB_Imp': cb.feature_importances_
})
# Normalizar min-max
for col in ['LGBM_Imp', 'CB_Imp']:
imp_df[f'{col}_Norm'] = (imp_df[col] - imp_df[col].min()) / (imp_df[col].max() - imp_df[col].min())
imp_df['Imp Promedio'] = imp_df[['LGBM_Imp_Norm', 'CB_Imp_Norm']].mean(axis=1)
imp_df['Imp Diferencia'] = imp_df['LGBM_Imp_Norm'] - imp_df['CB_Imp_Norm']
# Ordenar por Importancia Promedio
imp_df = imp_df.sort_values('Imp Promedio', ascending=False).reset_index(drop=True)
print("Importancia de TODAS las Variables (Normalizada y Absoluta):")
# Mostrar el dataframe completo
print(imp_df.to_markdown(index=False))
except Exception as e:
print(f"⚠️ Error al analizar importancia base: {e}")
# --- EJECUCIÓN PRINCIPAL ---
# Asegúrate de que X_train_val, y_train_val, etc. están cargados
# y que lr_best_params tiene el formato correcto (ej. {'lr_C': 0.1, ...})
# 1. Ejecutar Construcción y Predicción
y_test_real, y_pred_final, y_pred_proba, pipeline_entrenado = construir_y_predecir_modelo_final(
X_train_val, y_train_val, X_test, y_test,
lgbm_best_params, cb_best_params, lr_best_params, # Asegúrate de pasar lr_best_params (o lr_meta_params)
output_folder = 'results/final_model_fase1'
)
# 2. Evaluar
evaluar_modelo(y_test_real, y_pred_final, y_pred_proba)
# 3. Analizar
analizar_meta_estimator(pipeline_entrenado)
analizar_importancia_base(pipeline_entrenado, feature_names = X_train_val.columns.tolist())
✔️ Modelo encontrado. Cargando desde: results/final_model_fase1\stacking_final_pipeline.joblib ✅ Usando modelo previamente entrenado. Generando predicciones en X_test... --- 📊 2.1. Métricas de Rendimiento Estándar --- | Métrica | Valor | |:----------|--------:| | Accuracy | 0.9497 | | Precision | 0.1239 | | Recall | 0.6515 | | F1-Score | 0.2082 | | ROC AUC | 0.9256 | | PR AUC | 0.4429 | | FP Rate | 0.0472 | --- 💰 2.2. Métricas de Negocio --- | KPI | Valor | |:--------------------|:--------| | Total Test | 19517 | | Clientes Reales (1) | 198 | | Detectados (TP) | 129 | | Perdidos (FN) | 69 | | Falsas Alarmas (FP) | 912 | | Tasa de Captura | 65.15% | --- 💰 2.3. Curvas de Negocio (Lift y Gain) --- | Decil | %_Poblacion | Clientes_Acumulados | Gain_Acumulado | Lift_Acumulado | Tasa_Base | |--------:|:--------------|----------------------:|:-----------------|-----------------:|------------:| | 1 | 10% | 150 | 75.8% | 7.57 | 0.0101 | | 2 | 20% | 171 | 86.4% | 4.32 | 0.0101 | | 3 | 30% | 183 | 92.4% | 3.08 | 0.0101 | | 4 | 40% | 189 | 95.5% | 2.39 | 0.0101 | | 5 | 50% | 194 | 98.0% | 1.96 | 0.0101 | | 6 | 60% | 197 | 99.5% | 1.66 | 0.0101 | | 7 | 70% | 198 | 100.0% | 1.43 | 0.0101 | | 8 | 80% | 198 | 100.0% | 1.25 | 0.0101 | | 9 | 90% | 198 | 100.0% | 1.11 | 0.0101 | | 10 | 100% | 198 | 100.0% | 1 | 0.0101 | **Interpretación de Puntos Clave:** * 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **75.8%** de todos los clientes reales. * 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **7.57** en el primer decil significa que la tasa de conversión en ese grupo es **7.57 veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).
--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador --- El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base: | Modelo Base | Peso (Coef) | Peso Absoluto | |:--------------|--------------:|----------------:| | cb | 34.2676 | 34.2676 | | lgbm | 7.80607 | 7.80607 |
--- 🌳 3.2. Importancia de Variables (Nivel 1) --- INFO: Estimador CatBoost encontrado usando el alias 'cb'. Importancia de TODAS las Variables (Normalizada y Absoluta): | Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia | |:-------------------------------------------|-----------:|------------:|----------------:|--------------:|---------------:|-----------------:| | clicks_por_sesion | 2286 | 14.921 | 1 | 0.523261 | 0.76163 | 0.476739 | | sesiones_por_dia | 1466 | 12.3897 | 0.641295 | 0.434479 | 0.537887 | 0.206816 | | bondad_email_20 | 172 | 28.5138 | 0.0752406 | 1 | 0.53762 | -0.924759 | | total_fichas_consultadas | 1697 | 7.27137 | 0.742345 | 0.254964 | 0.498654 | 0.48738 | | usuarios_que_consultan_misma_primera_ficha | 1378 | 3.50398 | 0.6028 | 0.122831 | 0.362815 | 0.479969 | | antiguedad_comportamiento_fichas | 1200 | 2.1757 | 0.524934 | 0.076244 | 0.300589 | 0.44869 | | num_dias_sesiones | 878 | 4.13249 | 0.384077 | 0.144875 | 0.264476 | 0.239202 | | canal_SEO | 383 | 9.96413 | 0.167542 | 0.349407 | 0.258474 | -0.181866 | | dia_semana_registro | 750 | 1.99628 | 0.328084 | 0.0699511 | 0.199018 | 0.258133 | | tipo_usuario_PF | 258 | 5.18093 | 0.112861 | 0.181646 | 0.147254 | -0.0687854 | | canal_SEM | 226 | 4.31112 | 0.0988626 | 0.151139 | 0.125001 | -0.0522767 | | tiene_fichas | 10 | 4.71317 | 0.00437445 | 0.16524 | 0.0848074 | -0.160866 | | bondad_email_0 | 50 | 0.358596 | 0.0218723 | 0.0125127 | 0.0171925 | 0.00935958 | | bondad_email_-10 | 44 | 0.211925 | 0.0192476 | 0.00736849 | 0.013308 | 0.0118791 | | es_finde_registro | 2 | 0.353949 | 0.000874891 | 0.0123497 | 0.00661228 | -0.0114748 | | bondad_email_1 | 0 | 0.00183504 | 0 | 0 | 0 | 0 |
📌 Conclusiones de la evaluacion
La evaluación del modelo de stacking sobre el conjunto de prueba (X_test) confirma que la estrategia adoptada está explícitamente orientada a maximizar la captura de la clase positiva, priorizando el recall frente a la precisión. Este enfoque resulta coherente con escenarios de negocio en los que el coste de no identificar un cliente potencial es superior al de una acción comercial no efectiva.
Evaluación del rendimiento (métricas estándar y de negocio)
- Capacidad discriminativa: El modelo alcanza un ROC AUC de 0.9256 y un PR AUC de 0.4429. Dada la baja tasa base de la clase positiva (≈1.01%), este valor de PR AUC refleja una elevada capacidad para separar compradores reales del resto de usuarios, siendo una métrica especialmente adecuada en contextos de fuerte desbalance.
- Trade-off recall–precision: El clasificador obtiene un recall del 65.15%, identificando 129 de los 198 compradores reales presentes en el conjunto de test. Esta elevada tasa de captura se acompaña de una precisión moderada (12.39%) y un número significativo de falsos positivos (FP = 912), un compromiso aceptable en estrategias de adquisición y activación donde la prioridad es no perder oportunidades de conversión.
Potencial de negocio (curvas Gain y Lift)
El análisis de Gain y Lift pone de manifiesto un alto potencial de explotación comercial del modelo, validando su utilidad como herramienta de priorización.
- Alta concentración de conversión: Al seleccionar únicamente el 10% de usuarios con mayor probabilidad predicha (primer decil), el modelo captura el 75.8% de todos los compradores reales del conjunto de prueba, lo que evidencia una fuerte concentración de la señal de compra en un subconjunto reducido de usuarios.
- Eficiencia operativa: El Lift de 7.57 en el primer decil indica que la tasa de conversión en este grupo es más de siete veces superior a la tasa media, lo que se traduce en campañas altamente eficientes y con un retorno esperado significativamente mayor.
Ponderación del meta-estimador
El análisis de los coeficientes del meta-estimador (Regresión Logística de Nivel 2) muestra una contribución claramente dominante de CatBoost (coeficiente 34.27) frente a LightGBM (coeficiente 7.81).
- Predominio de CatBoost: Ambos modelos base aportan señal positiva a la predicción final, pero la contribución de CatBoost es aproximadamente 4.4 veces superior, lo que indica que sus predicciones out-of-fold son percibidas como más fiables por el meta-modelo.
- Implicación interpretativa: Este resultado es especialmente relevante dado el uso de penalización L1 en el meta-estimador, que tiende a seleccionar únicamente las señales más robustas, descartando aquellas redundantes o ruidosas.
Importancia de variables (nivel 1)
En el nivel de los modelos base, las variables de comportamiento —como clicks_por_sesion y sesiones_por_dia— presentan una elevada importancia, especialmente en LightGBM. Destaca asimismo la fuerte discrepancia en la variable bondad_email_20, que constituye la señal más relevante para CatBoost, pero no para LightGBM.
Estas diferencias subrayan la complementariedad entre ambos modelos y ponen de relieve oportunidades claras para la reingeniería de variables y el análisis de redundancias, aspectos que se abordarán en el siguiente apartado.
Ciclo 2 - Reingeniería de variables y optimización
Reingenieria de features
| Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia |
|---|---|---|---|---|---|---|
| clicks_por_sesion | 2286 | 14.921 | 1 | 0.523261 | 0.76163 | 0.476739 |
| sesiones_por_dia | 1466 | 12.3897 | 0.641295 | 0.434479 | 0.537887 | 0.206816 |
| bondad_email_20 | 172 | 28.5138 | 0.0752406 | 1 | 0.53762 | -0.924759 |
| total_fichas_consultadas | 1697 | 7.27137 | 0.742345 | 0.254964 | 0.498654 | 0.48738 |
| usuarios_que_consultan_misma_primera_ficha | 1378 | 3.50398 | 0.6028 | 0.122831 | 0.362815 | 0.479969 |
| antiguedad_comportamiento_fichas | 1200 | 2.1757 | 0.524934 | 0.076244 | 0.300589 | 0.44869 |
| num_dias_sesiones | 878 | 4.13249 | 0.384077 | 0.144875 | 0.264476 | 0.239202 |
| canal_SEO | 383 | 9.96413 | 0.167542 | 0.349407 | 0.258474 | -0.181866 |
| dia_semana_registro | 750 | 1.99628 | 0.328084 | 0.0699511 | 0.199018 | 0.258133 |
| tipo_usuario_PF | 258 | 5.18093 | 0.112861 | 0.181646 | 0.147254 | -0.0687854 |
| canal_SEM | 226 | 4.31112 | 0.0988626 | 0.151139 | 0.125001 | -0.0522767 |
| tiene_fichas | 10 | 4.71317 | 0.00437445 | 0.16524 | 0.0848074 | -0.160866 |
| bondad_email_0 | 50 | 0.358596 | 0.0218723 | 0.0125127 | 0.0171925 | 0.00935958 |
| bondad_email_-10 | 44 | 0.211925 | 0.0192476 | 0.00736849 | 0.013308 | 0.0118791 |
| es_finde_registro | 2 | 0.353949 | 0.000874891 | 0.0123497 | 0.00661228 | -0.0114748 |
| bondad_email_1 | 0 | 0.00183504 | 0 | 0 | 0 | 0 |
El análisis conjunto de la importancia de variables, obtenido a partir de la combinación de los modelos LightGBM y CatBoost, permite identificar patrones relevantes para la mejora del rendimiento predictivo. La consideración simultánea de importancias absolutas y normalizadas no solo facilita la evaluación de la contribución individual de cada predictor, sino que también pone de manifiesto el grado de alineación o divergencia entre ambos algoritmos. Esta información resulta especialmente valiosa para orientar decisiones posteriores de reingeniería de variables y refinamiento del modelo.
A partir de los resultados presentados en la Tabla anterior, se identifican las siguientes líneas de actuación:
-
Reincorporación estratégica de variables previamente descartadas
Los modelos de boosting utilizados —en particular CatBoost y LightGBM— muestran una elevada capacidad para manejar colinealidad y capturar relaciones complejas entre predictores. En este contexto, la eliminación temprana de variables basada en criterios estadísticos clásicos puede resultar excesivamente restrictiva. Por ello, se plantea la reintroducción selectiva de variables descartadas en fases previas, permitiendo que el propio modelo determine su utilidad efectiva. -
Aplicación de transformaciones para capturar efectos no lineales
Las diferencias sustanciales de importancia observadas entre modelos sugieren que determinados predictores presentan distribuciones asimétricas o efectos no lineales. En estos casos, la aplicación de transformaciones —como la logarítmica— puede contribuir a estabilizar la varianza y mejorar la capacidad de generalización. Variables comototal_fichas_consultadas,antiguedad_comportamiento_fichasyusuarios_que_consultan_misma_primera_fichase identifican como candidatas naturales para este tipo de tratamiento. -
Generación de variables derivadas para capturar interacciones
La creación de variables compuestas permite modelar sinergias entre predictores que no se reflejan explícitamente en el conjunto original de datos. Un ejemplo ilustrativo es la variabletotal_clicks_por_dia, obtenida a partir de la combinación declicks_por_sesionysesiones_por_dia. Este tipo de ingeniería de variables puede incrementar la capacidad explicativa del modelo y facilitar, en etapas posteriores, la reducción de redundancias. -
Refuerzo del papel de variables categóricas en combinación con señales de comportamiento
Se observa que determinados predictores categóricos —en particular las distintas modalidades de bondad_email— presentan una alta relevancia en CatBoost, mientras que su impacto es más limitado en LightGBM. Este comportamiento sugiere la conveniencia de construir indicadores compuestos que integren información categórica y comportamental, como índices de engagement_por_email. Asimismo, variables como tiene_fichas o es_finde_registro, aunque de menor peso individual, podrían adquirir mayor relevancia al combinarse con predictores dominantes.
En conjunto, estas propuestas buscan potenciar la complementariedad entre los modelos del ensemble, mitigar las limitaciones derivadas de distribuciones complejas y reforzar la arquitectura del conjunto de predictores. Este enfoque sistemático de ingeniería de variables constituye un elemento clave para la mejora del rendimiento del modelo de Stacking y sienta las bases para iteraciones futuras de optimización.
Creacion de features y dataset
La fase de reingeniería de variables se orientó a mejorar la capacidad explicativa del modelo y a facilitar la captura de patrones no lineales mediante la transformación y combinación estratégica de los predictores disponibles. Para ello, se abordaron de forma sistemática la optimización de tipos de datos, la generación de interacciones y ratios informativos, la aplicación de transformaciones para corregir asimetrías y la consolidación de señales débiles. La eliminación de variables se realizó únicamente en una etapa final, atendiendo a su contribución efectiva en el modelo optimizado.
| Acción | Descripción | Objetivo |
|---|---|---|
| Normalización de tipos | Conversión de variables a tipos category e int8. |
Reducir el consumo de memoria y optimizar el rendimiento de modelos basados en árboles (CatBoost y LightGBM). |
| Engagement por email | Creación de una interacción entre el número de clics y la variable bondad_email_20. |
Capturar sinergias entre la calidad de la comunicación y la respuesta activa del usuario. |
| Ratios de actividad | Cálculo de total_clicks_por_dia como combinación de clics y sesiones por día. |
Representar de forma más precisa la intensidad real de la actividad del usuario. |
| Transformaciones logarítmicas | Aplicación de transformaciones logarítmicas a variables con distribuciones altamente sesgadas. | Suavizar la distribución de los datos y facilitar particiones más estables en los árboles. |
| Interacciones binarias | Combinación de variables indicadoras (flags) con predictores numéricos. | Potenciar señales débiles y detectar patrones condicionados por características específicas del usuario. |
| Limpieza final de variables | Eliminación de predictores con baja contribución, evaluada tras el entrenamiento del modelo final. | Reducir el ruido, evitar sobreajuste y conservar únicamente variables con valor predictivo demostrado. |
df_cb_lgbm = df_final.copy()
# =============================================================================
# Transformar columnas object a int y object a category
# =============================================================================
to_int = ['mes_registro','es_finde_registro', 'tiene_fichas']
for int_col in to_int:
df_cb_lgbm[int_col] = df_cb_lgbm[int_col].astype('int8')
categorical_features = ['bondad_email','canal','tipo_usuario', 'dia_semana_registro']
for feature in categorical_features:
df_cb_lgbm[feature] = df_cb_lgbm[feature].astype('category')
# =============================================================================
# Potenciar en LGBM bondadEmail con engagement_por_email
# =============================================================================
df_cb_lgbm['engagement_por_email'] = 0.0
df_cb_lgbm.loc[df_cb_lgbm['bondad_email'] == '20', 'engagement_por_email'] = df_cb_lgbm['clicks_por_sesion']
# =============================================================================
# Ratios que capturen sinergias
# =============================================================================
df_cb_lgbm['total_clicks_por_dia'] = df_cb_lgbm['clicks_por_sesion'] * df_cb_lgbm['sesiones_por_dia']
# =============================================================================
# Suavizar distribuciones: antiguedad comportamiento, total_fichas_consultadas, usuarios_que_consultan_misma_primera_ficha
# =============================================================================
cols_to_log = [
'total_fichas_consultadas',
'usuarios_que_consultan_misma_primera_ficha',
]
for col in cols_to_log:
df_cb_lgbm[f'log_{col}'] = np.log1p(df_cb_lgbm[col])
# === DECOMPOSICIÓN DE LA ANTIGÜEDAD ===
# !Especial atencion a antiguedad comportamiento fichas, su valor sentinela -1 "dificulta la transformacion"!
antiguedad_col = 'antiguedad_comportamiento_fichas'
# Fecha unica (cuando solo hay 1 ficha se representa con un 0 (fecha_max - fecha_min = 0)
df_cb_lgbm['flag_ficha_unica'] = (df_cb_lgbm[antiguedad_col] == 0).astype(int)
# Antigüedad Continua Limpia (Solo valores > 0)
temp_antiguedad = df_cb_lgbm[antiguedad_col].copy()
temp_antiguedad[temp_antiguedad <= 0] = np.nan # Invalidar -1 y 0
# Aplicar Logaritmo (log natural)
df_cb_lgbm['log_antiguedad_real'] = np.log(temp_antiguedad)
# Imputación: Reemplazamos los Infinitos (NaN de los sentinelas) por 0.
df_cb_lgbm['log_antiguedad_real'] = df_cb_lgbm['log_antiguedad_real'].fillna(0)
# =============================================================================
# REFUERZO DE LA SEÑAL: tiene_fichas
# =============================================================================
# Clicks solo relevantes si el usuario tiene fichas
df_cb_lgbm["clicks_si_fichas"] = df_cb_lgbm["total_clicks_por_dia"] * df_cb_lgbm["tiene_fichas"]
# Popularidad de la ficha (peer signal) solo si tiene fichas
df_cb_lgbm["peer_signal_si_fichas"] = (
df_cb_lgbm["log_usuarios_que_consultan_misma_primera_ficha"] * df_cb_lgbm["tiene_fichas"]
)
# Cantidad de fichas consultadas solo si tiene fichas
df_cb_lgbm["fichas_consultadas_si_tiene"] = (
df_cb_lgbm["log_total_fichas_consultadas"] * df_cb_lgbm["tiene_fichas"]
)
# Fichas + email excelente → combinación muy fuerte
df_cb_lgbm["fichas_y_email_bueno"] = (
((df_cb_lgbm["bondad_email"] == "20").astype(int)) * df_cb_lgbm["tiene_fichas"]
)
# =============================================================================
# REFUERZO DE LA SEÑAL: flag_ficha_unica
# =============================================================================
# Peer signal * ficha única
df_cb_lgbm["peer_signal_unica"] = (
df_cb_lgbm["log_usuarios_que_consultan_misma_primera_ficha"] * df_cb_lgbm["flag_ficha_unica"]
)
# Antigüedad log * ficha única
df_cb_lgbm["recencia_ficha_unica"] = df_cb_lgbm["recencia_fichas"] * df_cb_lgbm["flag_ficha_unica"]
# =============================================================================
# REFUERZO DE LA SEÑAL: es_finde_registro
# =============================================================================
# Clicks después de registrarse en finde
df_cb_lgbm["clicks_despues_registro_finde"] = (
df_cb_lgbm["total_clicks_por_dia"] * df_cb_lgbm["es_finde_registro"]
)
# Sesiones * registro en finde
df_cb_lgbm["sesiones_finde"] = df_cb_lgbm["sesiones_por_dia"] * df_cb_lgbm["es_finde_registro"]
# Email excelente * registro en finde
df_cb_lgbm["email_bueno_finde"] = (
(df_cb_lgbm["bondad_email"] == "20").astype(int) * df_cb_lgbm["es_finde_registro"]
)
# PF * registro en finde
df_cb_lgbm["PF_finde"] = (df_cb_lgbm["tipo_usuario"] == 'PF').astype(int) * df_cb_lgbm["es_finde_registro"]
# =============================================================================
# Eliminar columnas confusas, mejoradas o irrelevantes
# =============================================================================
# Dia mes puede entorpecer ya que tiene muy baja correlacion, distribucion uniforme y tratarla como categorica seria conflictivo.
df_cb_lgbm = df_cb_lgbm.drop(columns=['dia_mes_registro'])
df_cb_lgbm
| canal | es_cliente | bondad_email | tipo_usuario | mes_registro | dia_semana_registro | es_finde_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | ... | clicks_si_fichas | peer_signal_si_fichas | fichas_consultadas_si_tiene | fichas_y_email_bueno | peer_signal_unica | recencia_ficha_unica | clicks_despues_registro_finde | sesiones_finde | email_bueno_finde | PF_finde | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Directorios | 0 | 9 | PF | 1 | 0 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 1 | Directorios | 0 | 20 | PF | 1 | 0 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 2 | Directorios | 0 | 20 | PF | 1 | 0 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 3 | Directorios | 0 | 20 | PF | 1 | 0 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 4 | Directorios | 0 | 20 | PF | 1 | 0 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 195160 | Directorios | 0 | 20 | PF | 12 | 4 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 195161 | Directorios | 0 | 20 | PF | 12 | 4 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 195162 | SEO | 0 | 20 | PF | 12 | 4 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 195163 | SEO | 0 | 20 | PJ | 12 | 4 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
| 195164 | Directorios | 0 | 20 | PF | 12 | 4 | 0 | 0 | 2306 | -1 | ... | 0.0 | 0.0 | 0.0 | 0 | 0.0 | 0 | 0.0 | 0.0 | 0 | 0 |
195165 rows × 33 columns
print(df_cb_lgbm.dtypes)
guardar_csv(df_cb_lgbm, "src/datasets_stacking", "df_cb_lgbm.csv")
canal category es_cliente int64 bondad_email category tipo_usuario category mes_registro int8 dia_semana_registro category es_finde_registro int8 total_fichas_consultadas int64 recencia_fichas int64 antiguedad_comportamiento_fichas int64 total_sesiones int64 total_clicks int64 num_dias_sesiones int64 clicks_por_sesion float64 sesiones_por_dia float64 usuarios_que_consultan_misma_primera_ficha Int64 tiene_fichas int8 engagement_por_email float64 total_clicks_por_dia float64 log_total_fichas_consultadas float64 log_usuarios_que_consultan_misma_primera_ficha Float64 flag_ficha_unica int64 log_antiguedad_real float64 clicks_si_fichas float64 peer_signal_si_fichas Float64 fichas_consultadas_si_tiene float64 fichas_y_email_bueno int64 peer_signal_unica Float64 recencia_ficha_unica int64 clicks_despues_registro_finde float64 sesiones_finde float64 email_bueno_finde int64 PF_finde int64 dtype: object ✅ df_cb_lgbm.csv guardado en src/datasets_stacking\df_cb_lgbm.csv
Dataset de entreno
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_cb_lgbm)
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada.
Optimizacion de hiperparámetros con optuna
Tras completar la reingeniería de variables, se llevó a cabo una revisión exhaustiva del proceso de optimización de hiperparámetros con el objetivo de adaptar el espacio de búsqueda a la nueva estructura del dataset. En esta fase, la optimización deja de ser exploratoria para adoptar un enfoque de fine-tuning dirigido, centrado en consolidar los óptimos previamente identificados, reducir redundancias y reforzar los mecanismos de regularización y generalización.
La estrategia seguida se basa en tres principios fundamentales:
- Focalización del espacio de búsqueda, ajustando los rangos alrededor de los valores óptimos obtenidos en ciclos anteriores y aumentando la granularidad donde se detectó mayor sensibilidad del modelo.
- Simplificación estructural, eliminando hiperparámetros altamente correlacionados o redundantes y delegando su control en los valores por defecto de los frameworks, cuando estos demostraron ser suficientemente robustos.
- Refuerzo de la regularización y la diversidad, incorporando nuevos hiperparámetros orientados a reducir el sobreajuste, mejorar la estabilidad del ensemble y disminuir la correlación entre modelos base en el stacking.
La Tabla siguiente resume de forma detallada los ajustes realizados para cada modelo, incluyendo los rangos previos, los óptimos alcanzados y las modificaciones introducidas en esta fase, junto con su justificación técnica.
| Modelo | Parámetro | Rango Previo | Óptimo Previo | Rango Actual Propuesto | Justificación del Ajuste |
|---|---|---|---|---|---|
| LightGBM (LGBM) | |||||
| LGBM | n_estimators | [200, 1500] (step=100) |
1200 | [800, 1500] (step=50) |
Se mantiene un rango alto cercano al óptimo previo (1200), reduciendo el límite inferior a 800 y refinando el paso para mejorar la granularidad de búsqueda. |
| LGBM | learning_rate | [0.005, 0.1] (log) |
0.0315 | [0.005, 0.05] (log) |
Se centra la exploración en valores más cercanos al óptimo previo, limitando el rango superior a 0.05 para evitar valores inestables. |
| LGBM | num_leaves | [10, 100] |
10 | [20, 150] |
Se amplía el rango para permitir mayor capacidad de modelado y captación de interacciones complejas. |
| LGBM | max_depth | [3, 12] |
8 | ELIMINADO | Se elimina por alta correlación con num_leaves, confiando en el valor por defecto del framework para controlar la complejidad del árbol. |
| LGBM | min_child_samples | [10, 80] |
56 | [20, 100] |
Se amplía el rango inferior para mayor flexibilidad con nuevas features, manteniendo un límite superior alto para garantizar hojas robustas. |
| LGBM | reg_alpha | [1e-8, 10.0] (log) |
0.0010 | [1e-4, 1.0] (log) |
Se centra la búsqueda en baja regularización, ajustando el rango superior a 1.0 y el inferior a 1e-4. |
| LGBM | reg_lambda | [1e-8, 10.0] (log) |
0.0028 | ELIMINADO | Debido a la alta correlación con reg_alpha, se opta por el valor por defecto, simplificando el espacio de búsqueda. |
| LGBM | feature_fraction (NUEVO) | - | - | [0.7, 1.0] |
Se introduce muestreo de features para prevenir sobreajuste y aumentar diversidad en el ensamble. |
| LGBM | boosting_type (NUEVO) | - | - | ['gbdt', 'dart'] |
DART reduce la correlación entre modelos base en stacking, mejorando la robustez del ensamble. |
| CatBoost (CB) | |||||
| CB | iterations | [200, 1500] (step=100) |
1200 | [800, 1500] (step=50) |
Ajuste similar a LGBM, refinando paso y rango inicial. |
| CB | learning_rate | [0.005, 0.1] (log) |
0.0100 | [0.005, 0.03] (log) |
Se centra la búsqueda cerca del óptimo previo (0.01), limitando el rango superior. |
| CB | depth | [3, 10] |
8 | [4, 10] |
Se elimina el valor más bajo para enfocar la búsqueda en rangos relevantes. |
| CB | l2_leaf_reg | [1e-4, 10.0] (log) |
1.4123 | [0.1, 5.0] |
Se centra la búsqueda en valores estándar de regularización L2. |
| CB | subsample | [0.6, 1.0] |
0.6114 | [0.6, 0.9] |
Se limita el máximo para evitar sobreajuste por ausencia de muestreo. |
| CB | min_data_in_leaf | [5, 50] |
25 | ELIMINADO | Se opta por valor por defecto para maximizar flexibilidad y focalizar regularización en random_strength. |
| CB | random_strength (NUEVO) | - | - | [0.1, 10.0] (log) |
Introduce aleatoriedad en las divisiones, reforzando la regularización y la generalización. |
| CB | grow_policy (NUEVO) | - | - | ['SymmetricTree', 'Depthwise'] |
Optimiza la política de crecimiento de los árboles, comparando robustez y eficiencia. |
| Meta-Modelo: Regresión Logística (LR) | |||||
| LR | C | [0.01, 1.0] (log) |
0.0100 | [0.001, 0.1] (log) |
Se amplía el rango inferior para aumentar regularización y se centra la búsqueda en valores de alta regularización. |
| LR | penalty | ['l1', 'l2'] |
l1 | ['l1', 'l2', 'elasticnet'] |
Se añade elasticnet como opción intermedia, optimizando el meta-modelo de stacking. |
| LR | solver (NUEVO) | - | - | ['saga'] |
Requerido para soportar elasticnet y penalizaciones L1/L2 en el meta-modelo. |
| LR | l1_ratio (NUEVO) | - | - | [0.0, 1.0] (Condicional) |
Controla la mezcla de L1 y L2 para elasticnet, utilizado únicamente si `penalty` es 'elasticnet'. |
En conjunto, esta redefinición del espacio de hiperparámetros permite una optimización más eficiente y controlada, alineada con la nueva representación del comportamiento del usuario y orientada a maximizar la capacidad de generalización del modelo final en un contexto de fuerte desbalance de clases.
# Redefinimos parametros:
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
def get_params_and_model(model_name, trial, categorical_features=None, random_state=42):
"""
Define el espacio de búsqueda de Optuna y devuelve los parámetros y el modelo.
El parámetro 'categorical_features' es opcional (por defecto es None).
Solo se usará si se proporciona al inicializar LGBM o CatBoost.
"""
if model_name == 'LGBM':
print("🛠️ Optuna: Configurando LightGBM (LGBM)...")
params = {
'n_estimators': trial.suggest_int('n_estimators', 800, 1500, step=50),
'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.05, log=True),
'num_leaves': trial.suggest_int('num_leaves', 20, 150),
'min_child_samples': trial.suggest_int('min_child_samples', 20, 100),
'reg_alpha': trial.suggest_float('reg_alpha', 1e-4, 1.0, log=True),
'feature_fraction': trial.suggest_float('feature_fraction', 0.7, 1.0),
'boosting_type': trial.suggest_categorical('boosting_type', ['gbdt', 'dart'])
}
# Diccionario para parámetros adicionales
lgbm_args = {
'random_state': random_state,
'eval_metric': 'average-precision',
'n_jobs': -1,
'verbose': -1,
# Añadir categóricas si existen
**({'categorical_feature': categorical_features} if categorical_features is not None else {})
}
model = LGBMClassifier(**params, **lgbm_args)
elif model_name == 'CatBoost':
print("🛠️ Optuna: Configurando CatBoost (CB)...")
params = {
'iterations': trial.suggest_int('iterations', 800, 1500, step=50),
'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.03, log=True),
'depth': trial.suggest_int('depth', 4, 10),
'l2_leaf_reg': trial.suggest_float('l2_leaf_reg', 0.1, 5.0),
'subsample': trial.suggest_float('subsample', 0.6, 0.9),
'random_strength': trial.suggest_float('random_strength', 0.1, 10.0, log=True),
'grow_policy': trial.suggest_categorical('grow_policy', ['SymmetricTree', 'Depthwise'])
}
# Diccionario para parámetros adicionales de CatBoost
catboost_args = {
'eval_metric':'PRAUC',
'random_state': random_state,
'verbose': 0,
'thread_count': -1,
'allow_writing_files': False,
# Añadir categóricas si existen (¡OJO: 'cat_features' PLURAL!)
**({'cat_features': categorical_features} if categorical_features is not None else {})
}
model = CatBoostClassifier(**params, **catboost_args)
elif model_name == 'LR_META':
print("🛠️ Optuna: Configurando Logistic Regression (Meta)...")
penalty = trial.suggest_categorical('lr_penalty', ['l1', 'l2'])
if penalty == 'l1':
# Para L1, solo liblinear funciona bien
solver = 'liblinear'
else: # penalty == 'l2'
# Para L2, varios solvers funcionan
solver = trial.suggest_categorical('lr_solver', ['lbfgs', 'newton-cg'])
lr_params = {
'C': trial.suggest_float('lr_C', 0.001, 0.1, log=True),
'penalty': trial.suggest_categorical('lr_penalty', ['l1', 'l2']),
'solver': solver,
'random_state': random_state,
'max_iter': 1000, # Añadir para asegurar convergencia
}
params = lr_params.copy()
# Crear el modelo de Logistic Regression
model = LogisticRegression(class_weight='balanced',**lr_params)
else:
raise ValueError(f"Modelo no soportado: {model_name}. Use 'LGBM', 'CatBoost' o 'LR_META'.")
return params, model
print(f"Started optimization on dataset:\n")
cat_cols = categorical_features
print(f"Categorical categories = {cat_cols}")
display(X_train_val.head(1))
lgbm_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='LGBM', categorical_features=None, folder_name="results/tuning/params_fase5", n_trials=100)
cb_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='CatBoost', categorical_features = None, folder_name="results/tuning/params_fase5", n_trials=100)
# 2. Generar (o cargar) Features de Nivel 2
X_level2 = generate_oof_predictions(X_train_val, y_train_val, lgbm_best_params, cb_best_params, cat_cols, "results/oof_features_fase5")
# 3. Optimizar (o cargar) el Meta-Estimador
lr_best_params, lr_best_score = run_meta_tuning(X_level2, y_train_val,folder_name="results/tuning/params_fase5", n_trials=50)
print("\n--- RESUMEN DE MEJORES PARÁMETROS ---")
print(f"LGBM: {lgbm_best_params}")
print(f"CatBoost: {cb_best_params}")
print(f"LR Meta: {lr_best_params}")
Started optimization on dataset: Categorical categories = ['bondad_email', 'canal', 'tipo_usuario', 'dia_semana_registro']
| canal | bondad_email | tipo_usuario | mes_registro | dia_semana_registro | es_finde_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | ... | clicks_si_fichas | peer_signal_si_fichas | fichas_consultadas_si_tiene | fichas_y_email_bueno | peer_signal_unica | recencia_ficha_unica | clicks_despues_registro_finde | sesiones_finde | email_bueno_finde | PF_finde | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 111955 | Directorios | 20 | PF | 8 | 4 | 0 | 1 | 1069 | 0 | 2 | ... | 4.0 | 3.044522 | 0.693147 | 1 | 3.044522 | 1069 | 0.0 | 0.0 | 0 | 0 |
1 rows × 32 columns
✔️ Cargando parámetros desde results/tuning/params_fase5\lgbm_best_params.json
🎉 Resultados cargados para LGBM. No se requiere re-optimizar.
✔️ Cargando parámetros desde results/tuning/params_fase5\catboost_best_params.json
🎉 Resultados cargados para CatBoost. No se requiere re-optimizar.
✅ Resultados previos de OOF encontrados en results/oof_features_fase5\X_level2_oof.csv.
Cargando y devolviendo resultados existentes...
✔️ Cargando parámetros desde results/tuning/params_fase5\lr_meta_best_params.json
🎉 Resultados cargados para Logistic Regression (Meta-Estimador).
--- RESUMEN DE MEJORES PARÁMETROS ---
LGBM: {'lgbm_n_estimators': 1800, 'lgbm_learning_rate': 0.018067869990858458, 'lgbm_num_leaves': 26, 'lgbm_min_child_samples': 30, 'lgbm_reg_alpha': 0.005330854069554935, 'lgbm_reg_lambda': 0.09987730382558391, 'lgbm_feature_fraction': 0.9170603878199113, 'lgbm_min_gain_to_split': 0.06584088996754021}
CatBoost: {'iterations': 850, 'learning_rate': 0.02542971271126076, 'depth': 9, 'l2_leaf_reg': 4.4036714336001195, 'subsample': 0.7158093626479908, 'random_strength': 0.863135324261114, 'grow_policy': 'SymmetricTree'}
LR Meta: {'lr_penalty': 'l2', 'lr_solver': 'lbfgs', 'lr_C': 0.09946667091710057}
Evaluación del modelo
# Asegúrate de que X_train_val, y_train_val, etc. están cargados
# y que lr_best_params tiene el formato correcto (ej. {'lr_C': 0.1, ...})
cat_cols = ['bondad_email','tipo_usuario', 'canal', 'dia_semana_registro']
df_cb_lgbm['Engagement_por_Email'] = 0.0
df_cb_lgbm.loc[df_cb_lgbm['bondad_email'] == '20', 'engagement_por_email'] = df_cb_lgbm['clicks_por_sesion']
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_cb_lgbm)
# 1. Ejecutar Construcción y Predicción
y_test_real, y_pred_final, y_pred_proba, pipeline_entrenado = construir_y_predecir_modelo_final(
X_train_val, y_train_val, X_test, y_test,
lgbm_best_params, cb_best_params, lr_best_params, cat_cols,
output_folder = 'results/final_model_fase2'
)
df_cb_lgbm['engagement_por_email'] = 0.0
df_cb_lgbm.loc[df_cb_lgbm['bondad_email'] == '20', 'engagement_por_email'] = df_cb_lgbm['clicks_por_sesion']
# 2. Evaluar
evaluar_modelo(y_test_real, y_pred_final, y_pred_proba)
# 3. Analizar
analizar_meta_estimator(pipeline_entrenado)
analizar_importancia_base(pipeline_entrenado, X_train_val.columns.tolist())
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada. ✔️ Modelo encontrado. Cargando desde: results/final_model_fase2\stacking_final_pipeline.joblib ✅ Usando modelo previamente entrenado. Generando predicciones en X_test... --- 📊 2.1. Métricas de Rendimiento Estándar --- | Métrica | Valor | |:----------|--------:| | Accuracy | 0.8989 | | Precision | 0.0685 | | Recall | 0.7121 | | F1-Score | 0.1251 | | ROC AUC | 0.9061 | | PR AUC | 0.3965 | | FP Rate | 0.0992 | --- 💰 2.2. Métricas de Negocio --- | KPI | Valor | |:--------------------|:--------| | Total Test | 19517 | | Clientes Reales (1) | 198 | | Detectados (TP) | 141 | | Perdidos (FN) | 57 | | Falsas Alarmas (FP) | 1916 | | Tasa de Captura | 71.21% | --- 💰 2.3. Curvas de Negocio (Lift y Gain) --- | Decil | %_Poblacion | Clientes_Acumulados | Gain_Acumulado | Lift_Acumulado | Tasa_Base | |--------:|:--------------|----------------------:|:-----------------|-----------------:|------------:| | 1 | 10% | 141 | 71.2% | 7.12 | 0.0101 | | 2 | 20% | 166 | 83.8% | 4.19 | 0.0101 | | 3 | 30% | 181 | 91.4% | 3.05 | 0.0101 | | 4 | 40% | 185 | 93.4% | 2.34 | 0.0101 | | 5 | 50% | 191 | 96.5% | 1.93 | 0.0101 | | 6 | 60% | 193 | 97.5% | 1.62 | 0.0101 | | 7 | 70% | 197 | 99.5% | 1.42 | 0.0101 | | 8 | 80% | 198 | 100.0% | 1.25 | 0.0101 | | 9 | 90% | 198 | 100.0% | 1.11 | 0.0101 | | 10 | 100% | 198 | 100.0% | 1 | 0.0101 | **Interpretación de Puntos Clave:** * 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **71.2%** de todos los clientes reales. * 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **7.12** en el primer decil significa que la tasa de conversión en ese grupo es **7.12 veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).
--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador --- El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base: | Modelo Base | Peso (Coef) | Peso Absoluto | |:--------------|--------------:|----------------:| | cb | 22.4586 | 22.4586 | | lgbm | 11.8748 | 11.8748 |
--- 🌳 3.2. Importancia de Variables (Nivel 1) --- INFO: Estimador CatBoost encontrado usando el alias 'cb'. Importancia de TODAS las Variables (Normalizada y Absoluta): | Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia | |:-----------------------------------------------|-----------:|-----------:|----------------:|--------------:|---------------:|-----------------:| | total_clicks_por_dia | 203 | 17.9861 | 0.581662 | 1 | 0.790831 | -0.418338 | | canal | 162 | 13.9181 | 0.464183 | 0.773826 | 0.619005 | -0.309642 | | total_clicks | 264 | 7.83189 | 0.756447 | 0.435442 | 0.595944 | 0.321005 | | recencia_fichas | 349 | 2.51055 | 1 | 0.139583 | 0.569791 | 0.860417 | | usuarios_que_consultan_misma_primera_ficha | 341 | 2.23178 | 0.977077 | 0.124084 | 0.550581 | 0.852994 | | mes_registro | 195 | 6.16608 | 0.558739 | 0.342825 | 0.450782 | 0.215914 | | total_fichas_consultadas | 260 | 2.17802 | 0.744986 | 0.121095 | 0.43304 | 0.623891 | | clicks_por_sesion | 147 | 6.31388 | 0.421203 | 0.351042 | 0.386123 | 0.0701611 | | sesiones_por_dia | 143 | 4.87509 | 0.409742 | 0.271048 | 0.340395 | 0.138694 | | clicks_despues_registro_finde | 203 | 1.6876 | 0.581662 | 0.0938282 | 0.337745 | 0.487834 | | log_total_fichas_consultadas | 124 | 4.02819 | 0.355301 | 0.223961 | 0.289631 | 0.13134 | | tipo_usuario | 85 | 5.37517 | 0.243553 | 0.298851 | 0.271202 | -0.0552984 | | total_sesiones | 92 | 2.44686 | 0.26361 | 0.136042 | 0.199826 | 0.127568 | | antiguedad_comportamiento_fichas | 111 | 0.539555 | 0.318052 | 0.0299984 | 0.174025 | 0.288053 | | bondad_email | 20 | 3.39154 | 0.0573066 | 0.188565 | 0.122936 | -0.131258 | | peer_signal_si_fichas | 42 | 1.82279 | 0.120344 | 0.101344 | 0.110844 | 0.0189996 | | dia_semana_registro | 36 | 1.6199 | 0.103152 | 0.0900639 | 0.0966079 | 0.013088 | | fichas_consultadas_si_tiene | 48 | 0.833906 | 0.137536 | 0.0463639 | 0.0919499 | 0.0911719 | | recencia_ficha_unica | 45 | 0.797803 | 0.12894 | 0.0443567 | 0.0866483 | 0.0845831 | | peer_signal_unica | 18 | 2.10644 | 0.0515759 | 0.117115 | 0.0843455 | -0.0655391 | | fichas_y_email_bueno | 0 | 2.85703 | 0 | 0.158847 | 0.0794233 | -0.158847 | | sesiones_finde | 44 | 0.304859 | 0.126074 | 0.0169497 | 0.0715121 | 0.109125 | | num_dias_sesiones | 29 | 0.970369 | 0.0830946 | 0.0539511 | 0.0685228 | 0.0291434 | | log_usuarios_que_consultan_misma_primera_ficha | 0 | 2.24844 | 0 | 0.12501 | 0.062505 | -0.12501 | | flag_ficha_unica | 0 | 2.13251 | 0 | 0.118564 | 0.0592822 | -0.118564 | | tiene_fichas | 0 | 1.8221 | 0 | 0.101306 | 0.050653 | -0.101306 | | email_bueno_finde | 23 | 0.267972 | 0.0659026 | 0.0148988 | 0.0404007 | 0.0510038 | | es_finde_registro | 8 | 0.079603 | 0.0229226 | 0.00442581 | 0.0136742 | 0.0184968 | | Engagement_por_Email | 7 | 0.0517262 | 0.0200573 | 0.0028759 | 0.0114666 | 0.0171814 | | clicks_si_fichas | 0 | 0.402216 | 0 | 0.0223626 | 0.0111813 | -0.0223626 | | log_antiguedad_real | 1 | 0.123797 | 0.00286533 | 0.00688293 | 0.00487413 | -0.0040176 | | PF_finde | 0 | 0.0781648 | 0 | 0.00434585 | 0.00217293 | -0.00434585 | | engagement_por_email | 0 | 0 | 0 | 0 | 0 | 0 |
📌 Conclusiones
El proceso de reingeniería de variables aplicado al modelo de stacking permitió identificar varios aciertos que contribuyeron de forma sustantiva al desempeño predictivo. Entre ellos, destaca la incorporación de señales de alto valor informativo, particularmente aquellas relacionadas con el engagement por email, cuya reformulación facilitó que LightGBM capturara con mayor claridad su contribución marginal. Asimismo, variables como total_clicks_por_dia y recencia_ficha_unica mostraron una capacidad predictiva notable, especialmente cuando esta última se reforzó mediante su combinación con la variable global de recencia.
De forma complementaria, se constató que las variables categóricas ganan relevancia incluso sin procesos de codificación adicionales, en línea con las capacidades nativas de CatBoost. En este contexto, la reincorporación de variables como mes_registro y recencia resultó adecuada, ya que aportan información temporal relevante que el modelo es capaz de explotar de manera efectiva.
Por el contrario, algunas de las transformaciones evaluadas no produjeron mejoras apreciables en el rendimiento del sistema. La aplicación de transformaciones logarítmicas —en particular sobre total_fichas_consultadas— no aportó beneficios y, de hecho, se observó que la versión original de la variable retenía una mayor influencia en el modelo. Asimismo, la variable bondad_email perdió protagonismo al quedar parcialmente solapada con la señal reforzada capturada a través de engagement_email, lo que sugiere una redundancia conceptual entre ambas. Las combinaciones generadas a partir de la variable es_finde tampoco ofrecieron mejoras significativas, indicando que la información contextual asociada al fin de semana no resulta especialmente discriminativa en este caso.
Tras diversas iteraciones de entrenamiento manual y la aplicación de un procedimiento sistemático de selección automática de variables —que se detalla en la sección siguiente— se procedió a depurar el conjunto final de predictores. En particular, se eliminaron aquellas variables cuya importancia media normalizada se situó de forma consistente por debajo de 0.1. El análisis de importancias revela una clara concentración del peso predictivo en un subconjunto reducido de señales, lo que respalda la decisión de simplificación y contribuye tanto a mejorar la interpretabilidad como a optimizar la eficiencia del modelo final.
Eliminacion de caracteristicas y reevaluacion
delete_cols= [
'Engagement_por_Email',
'log_total_fichas_consultadas',
'log_usuarios_que_consultan_misma_primera_ficha',
'email_bueno_finde',
'PF_finde',
'flag_ficha_unica',
'es_finde_registro',
'sesiones_finde',
'log_antiguedad_real',
'clicks_despues_registro_finde',#Devuelvela 15->16
'fichas_consultadas_si_tiene',
'tiene_fichas', #Señal mejor capturada con las combinadas
'peer_signal_si_fichas',
'peer_signal_unica',
'fichas_y_email_bueno', #Penaliza demasiado a LGBM y su informacion esta en engagement_por_email
'num_dias_sesiones', #Penaliza demasiado a CatBoost
'dia_semana_registro',
'bondad_email'
]
df_final_cb_lgbm = df_cb_lgbm.drop(columns=delete_cols)
# Asegúrate de que X_train_val, y_train_val, etc. están cargados
# y que lr_best_params tiene el formato correcto (ej. {'lr_C': 0.1, ...})
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_final_cb_lgbm)
# 1. Ejecutar Construcción y Predicción
new_cat_cols =['canal', 'tipo_usuario']
y_test_real, y_pred_final, y_pred_proba, pipeline_entrenado = construir_y_predecir_modelo_final(
X_train_val, y_train_val, X_test, y_test,
lgbm_best_params, cb_best_params, lr_best_params, new_cat_cols,
output_folder = 'results/final_model_fase25'
)
# 2. Evaluar
evaluar_modelo(y_test_real, y_pred_final, y_pred_proba)
# 3. Analizar
analizar_meta_estimator(pipeline_entrenado)
analizar_importancia_base(pipeline_entrenado, X_train_val.columns.tolist())
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada. ✔️ Modelo encontrado. Cargando desde: results/final_model_fase25\stacking_final_pipeline.joblib ✅ Usando modelo previamente entrenado. Generando predicciones en X_test... --- 📊 2.1. Métricas de Rendimiento Estándar --- | Métrica | Valor | |:----------|--------:| | Accuracy | 0.955 | | Precision | 0.1366 | | Recall | 0.6465 | | F1-Score | 0.2256 | | ROC AUC | 0.9258 | | PR AUC | 0.4726 | | FP Rate | 0.0419 | --- 💰 2.2. Métricas de Negocio --- | KPI | Valor | |:--------------------|:--------| | Total Test | 19517 | | Clientes Reales (1) | 198 | | Detectados (TP) | 128 | | Perdidos (FN) | 70 | | Falsas Alarmas (FP) | 809 | | Tasa de Captura | 64.65% | --- 💰 2.3. Curvas de Negocio (Lift y Gain) --- | Decil | %_Poblacion | Clientes_Acumulados | Gain_Acumulado | Lift_Acumulado | Tasa_Base | |--------:|:--------------|----------------------:|:-----------------|-----------------:|------------:| | 1 | 10% | 150 | 75.8% | 7.57 | 0.0101 | | 2 | 20% | 173 | 87.4% | 4.37 | 0.0101 | | 3 | 30% | 183 | 92.4% | 3.08 | 0.0101 | | 4 | 40% | 188 | 94.9% | 2.37 | 0.0101 | | 5 | 50% | 191 | 96.5% | 1.93 | 0.0101 | | 6 | 60% | 197 | 99.5% | 1.66 | 0.0101 | | 7 | 70% | 198 | 100.0% | 1.43 | 0.0101 | | 8 | 80% | 198 | 100.0% | 1.25 | 0.0101 | | 9 | 90% | 198 | 100.0% | 1.11 | 0.0101 | | 10 | 100% | 198 | 100.0% | 1 | 0.0101 | **Interpretación de Puntos Clave:** * 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **75.8%** de todos los clientes reales. * 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **7.57** en el primer decil significa que la tasa de conversión en ese grupo es **7.57 veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).
--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador --- El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base: | Modelo Base | Peso (Coef) | Peso Absoluto | |:--------------|--------------:|----------------:| | cb | 22.0178 | 22.0178 | | lgbm | 12.2636 | 12.2636 |
--- 🌳 3.2. Importancia de Variables (Nivel 1) --- INFO: Estimador CatBoost encontrado usando el alias 'cb'. Importancia de TODAS las Variables (Normalizada y Absoluta): | Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia | |:-------------------------------------------|-----------:|---------:|----------------:|--------------:|---------------:|-----------------:| | engagement_por_email | 162 | 24.2434 | 0.305648 | 1 | 0.652824 | -0.694352 | | usuarios_que_consultan_misma_primera_ficha | 371 | 5.66669 | 1 | 0.1987 | 0.59935 | 0.8013 | | recencia_fichas | 354 | 4.46326 | 0.943522 | 0.146791 | 0.545156 | 0.796731 | | total_fichas_consultadas | 281 | 6.46916 | 0.700997 | 0.233314 | 0.467155 | 0.467683 | | canal | 162 | 15.442 | 0.305648 | 0.620352 | 0.463 | -0.314704 | | total_clicks | 254 | 7.39645 | 0.611296 | 0.273312 | 0.442304 | 0.337983 | | clicks_por_sesion | 219 | 7.40875 | 0.495017 | 0.273843 | 0.38443 | 0.221173 | | mes_registro | 217 | 6.55909 | 0.488372 | 0.237193 | 0.362783 | 0.251179 | | recencia_ficha_unica | 257 | 3.02286 | 0.621262 | 0.0846593 | 0.352961 | 0.536603 | | sesiones_por_dia | 145 | 4.83125 | 0.249169 | 0.162664 | 0.205917 | 0.0865057 | | antiguedad_comportamiento_fichas | 165 | 1.06018 | 0.315615 | 0 | 0.157807 | 0.315615 | | total_clicks_por_dia | 142 | 2.62803 | 0.239203 | 0.0676286 | 0.153416 | 0.171574 | | tipo_usuario | 94 | 5.502 | 0.0797342 | 0.191596 | 0.135665 | -0.111862 | | total_sesiones | 107 | 2.8488 | 0.122924 | 0.0771513 | 0.100037 | 0.0457723 | | clicks_si_fichas | 70 | 2.45808 | 0 | 0.0602979 | 0.0301489 | -0.0602979 |
📌 Conclusiones
La depuración del conjunto de variables produjo una mejora ligera pero consistente del rendimiento, especialmente en métricas clave para escenarios desbalanceados. En concreto, se observa un incremento del PR-AUC (0.4665 → 0.4726) y del recall (63.1% → 64.6%), lo que indica que la eliminación de predictores poco informativos redujo ruido y permitió al modelo concentrarse en señales más estables, sin deteriorar las métricas de negocio (Lift y Gain), que se mantienen prácticamente invariantes.
Selección automática de variables (SFFS)
Con el fin de explorar una posible reducción del espacio de variables y analizar su contribución individual al rendimiento del modelo, se implementó un procedimiento de selección automática de características. El objetivo principal de esta fase fue obtener una visión complementaria sobre la relevancia de los predictores, más que mejorar directamente el desempeño del modelo final.
Para ello se utilizó el método SFFS (Sequential Floating Forward Selection), una técnica de tipo wrapper que evalúa subconjuntos de variables entrenando los modelos reales en cada iteración. El algoritmo combina una fase de adición progresiva de variables con una fase condicional de eliminación, lo que permite mitigar redundancias y efectos de colinealidad dinámica.
La implementación se adaptó a la arquitectura de stacking empleada, evaluando cada subconjunto mediante una métrica de PR-AUC ponderada según la contribución relativa de los modelos base (CatBoost y LightGBM), y utilizando validación cruzada estratificada para garantizar estabilidad en un contexto de fuerte desbalance de clases.
Si bien este enfoque permitió identificar patrones consistentes en las variables más influyentes desde una perspectiva individual, no produjo mejoras relevantes respecto al conjunto de predictores ya optimizado. Por este motivo, sus resultados se emplean únicamente como apoyo interpretativo y se discuten en las conclusiones, sin incorporarse al modelo final.
# SFS / SFFS optimizado para LGBM y CatBoost con Parámetros Óptimos
import os
import pandas as pd
import numpy as np
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import average_precision_score
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
from sklearn.base import clone
import matplotlib.pyplot as plt
# -------------------------
# 3. Utilidades
# -------------------------
def safe_cv_score(model, X_sub, y, n_splits=3):
"""
Devuelve promedio de PR-AUC. Maneja columnas categóricas automáticamente
si el DF las tiene definidas como 'category'.
"""
skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=random_state)
scores = []
# Lista de columnas categóricas en este subconjunto
current_cats = [c for c in X_sub.columns if X_sub[c].dtype.name == 'category']
for train_idx, test_idx in skf.split(X_sub, y):
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
# Validación de seguridad para desbalance extremo
if y_train.sum() < 2 or y_test.sum() < 2:
print("!Sin positivos en este fold!")
continue
X_train, X_test = X_sub.iloc[train_idx], X_sub.iloc[test_idx]
# Clonar y entrenar
model_clone = clone(model)
# Manejo específico para CatBoost si es necesario, aunque con dtypes suele bastar.
# LGBM lo maneja nativo con dtypes.
try:
if isinstance(model_clone, CatBoostClassifier) and len(current_cats) > 0:
model_clone.fit(X_train, y_train, cat_features=current_cats)
else:
model_clone.fit(X_train, y_train)
preds = model_clone.predict_proba(X_test)[:, 1]
score = average_precision_score(y_test, preds)
if not np.isnan(score):
scores.append(score)
except Exception as e:
print("Error en score de 1 fold")
# Capturar errores de dimensionalidad o categorías y seguir
# print(f"Error en fold: {e}")
continue
if len(scores) == 0:
return 0.0
return float(np.mean(scores))
# Prefiltro univariante (Solo se usa si prefilter_k no es None)
def univariate_ranking(X, y, models, n_splits=3):
scores = {}
print(f"Iniciando ranking univariado de {X.shape[1]} variables...")
for col in X.columns:
X_col = X[[col]]
scs = []
for name, model in models.items():
sc = safe_cv_score(model, X_col, y, n_splits=n_splits)
scs.append(sc)
scores[col] = np.mean(scs)
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return ranked
def sfs_sffs(X, y, models, max_features=20, floating=True, prefilter_k=15, n_splits=3, results_file="features_auto_selection_sfs.csv"):
candidate_features = list(X.columns)
# ----------------------------------------------------
# LÓGICA DE CHECKPOINTING / REANUDACIÓN (AÑADIDO)
# ----------------------------------------------------<
selected = []
results = []
best_avg_global = -np.inf
start_step = 1
if os.path.exists(results_file):
try:
results_df_prev = pd.read_csv(results_file)
if not results_df_prev.empty:
# 1. Recuperar el mejor score global
best_row = results_df_prev.iloc[-1]
best_avg_global = best_row['avg_score']
# 2. Recuperar la lista de features seleccionadas (último estado)
# La columna 'features_list' se guarda como string, necesitamos convertirla a lista
selected_str = best_row['features_list'].strip('[]')
selected = [item.strip().strip("'\"") for item in selected_str.split(', ') if item.strip()]
# 3. Actualizar el paso de inicio
# Si el último paso fue una 'add', comenzamos en el siguiente.
start_step = best_row['step'] + 1
# 4. Cargar el historial completo de resultados
results = results_df_prev.to_dict('records')
print(f"============================================================")
print(f"✅ Cargando informacion: {results_file}")
print(f" - Ultimo paso: {start_step - 1}")
print(f" - Features: {len(selected)} -> {selected}")
print(f" - Mejor Score Global: {best_avg_global:.5f}")
print(f"============================================================")
return selected, pd.DataFrame(results)
except Exception as e:
print(f"ADVERTENCIA: Error al cargar el checkpoint ({e}). Iniciando desde cero.")
pass # Si falla la carga, simplemente iniciamos desde cero (el estado por defecto)
# ----------------------------------------------------
# 1) Prefiltro opcional: SOLO se ejecuta si se inicia desde cero (start_step == 1)
if prefilter_k is not None and prefilter_k < len(candidate_features):
if start_step == 1:
print(f"Prefiltrando top {prefilter_k} features...")
# Aquí iría la llamada a univariate_ranking()
# ranked = univariate_ranking(X, y, models, n_splits=n_splits)
# candidate_features = [f for f, s in ranked[:prefilter_k]]
print("Candidatos seleccionados:", candidate_features)
else:
# Reanudación: Se salta el prefiltro porque ya se hizo en Paso 1.
print(f"⏩ Saltando el paso de prefiltrado (ya ejecutado en Paso 1).")
else:
# No hay prefiltro o es demasiado amplio.
print(f"Sin prefiltro. Explorando las {len(candidate_features)} features.")
# Parámetro de parada: mejora mínima requerida para continuar
min_improvement = 1e-5 # Mínimo 0.00001 de mejora en el score para continuar
for step in range(start_step, max_features + 1):
# LÍNEA AÑADIDA PARA CONFIRMAR EL PASO DE INICIO
print(f"\n=== INICIANDO PASO {step} (Target Max: {max_features}) ===")
# --- FORWARD STEP ---
# Inicialización de variables para encontrar el mejor candidato en este paso
best_candidate = None
best_candidate_scores = None
best_avg_step = -np.inf
# Búsqueda de la mejor feature para añadir al conjunto 'selected':
# Iterar sobre candidatos restantes
# **Asegúrate de que 'features_to_try' solo incluye candidatos NO seleccionados.**
features_to_try = [f for f in candidate_features if f not in selected]
# Si no quedan features por probar, salimos
if not features_to_try and len(selected) < max_features:
print("🛑 No quedan features candidatas para añadir. Parando.")
break
for feat in features_to_try:
current_features = selected + [feat]
X_sub = X[current_features]
# Evaluar en ambos modelos
model_scores = {}
for name, model in models.items():
model_scores[name] = safe_cv_score(model, X_sub, y, n_splits=n_splits)
# CÓDIGO MEJORADO (Promedio Ponderado):
P_CB = 0.50 # Peso de CatBoost en el Stacking final
P_LGBM = 0.50 # Peso de LightGBM en el Stacking final
score_cb = model_scores['CatBoost']
score_lgbm = model_scores['LightGBM']
avg_score = (P_CB * score_cb) + (P_LGBM * score_lgbm)
# Guardar si es el mejor de este paso
if avg_score > best_avg_step:
best_avg_step = avg_score
best_candidate = feat
best_candidate_scores = model_scores
# (Opcional) Imprimir progreso en tiempo real si se desea
# print(f" + {feat}: {avg_score:.4f}") # Quitar o comentar para ejecución larga
# Comprobar mejora respecto al paso anterior
if best_candidate is None or (best_avg_step - best_avg_global) < min_improvement:
print(f"🛑 No hay mejora significativa ({min_improvement}). Parando.")
break
# Aceptar candidato
selected.append(best_candidate)
best_avg_global = best_avg_step
print(f"✅ ACEPTADO: {best_candidate} | PR-AUC Promedio: {best_avg_global:.5f}")
# print(f" Detalle: {best_candidate_scores}")
# Añadir y guardar el paso (MANDATORIO)
results.append({
"step": step,
"action": "add",
"feature": best_candidate,
"features_list": str(list(selected)), # Guardar como string para CSV
"avg_score": best_avg_global,
**best_candidate_scores
})
# Guardado parcial de seguridad
pd.DataFrame(results).to_csv(OUT_CSV, index=False)
# --- BACKWARD STEP (Floating) ---
if floating and len(selected) > 2:
# Lógica para determinar si alguna feature seleccionada puede ser eliminada
print(" Values floating (backward check)...")
# Probar quitar cada feature seleccionada
for feat_to_remove in selected:
if feat_to_remove == best_candidate: continue
trial_sel = [f for f in selected if f != feat_to_remove]
X_sub = X[trial_sel]
model_scores = {}
for name, model in models.items():
model_scores[name] = safe_cv_score(model, X_sub, y, n_splits=n_splits)
avg_rem = np.mean(list(model_scores.values()))
if avg_rem > best_avg_global + 1e-6: # Solo si mejora estrictamente
print(f" ! Quitar {feat_to_remove} mejora el score: {best_avg_global:.5f} -> {avg_rem:.5f}")
# Ejecutamos la eliminación
selected.remove(feat_to_remove)
best_avg_global = avg_rem
results.append({
"step": step,
"action": "remove",
"feature": feat_to_remove,
"features_list": str(list(selected)), # Guardar como string para CSV
"avg_score": best_avg_global,
**model_scores
})
# Guardar inmediatamente después de un REMOVE exitoso
pd.DataFrame(results).to_csv(OUT_CSV, index=False)
break # Solo removemos una por paso para simplificar
return selected, pd.DataFrame(results)
# -------------------------
# 0. Preparación de Datos
# -------------------------
cat_cols = ['canal', 'bondad_email', 'tipo_usuario', 'dia_semana_registro']
for col in cat_cols:
if col in df_cb_lgbm.columns:
df_cb_lgbm[col] = df_cb_lgbm[col].astype('category')
# -------------------------
# 1. Configuración de Modelos (USANDO TUS BEST PARAMS)
# -------------------------
# Definición usando los diccionarios de mejores parámetros que ya tienes cargados
# Asegurar de que lgbm_best_params y cb_best_params existen en el entorno
print("Configurando modelos con parámetros óptimos previos...")
N_ESTIMATORS_REDUCED = 500 # EJEMPLO
ITERATIONS_REDUCED = 500 # EJEMPLO
lgbm_final = LGBMClassifier(
**{**lgbm_best_params,# Primero desempaqueta best_params
'eval_metric': 'average-precision',
'random_state': 42, # Luego añade/sobrescribe
'n_jobs': -1,
'verbose': -1,
'n_estimators': N_ESTIMATORS_REDUCED}
)
cb_final = CatBoostClassifier(
**{**cb_best_params,
'eval_metric':'PRAUC',
'random_state': 42,
'verbose': 0,
'thread_count': -1,
'allow_writing_files': False,
'train_dir': None,
'iterations': ITERATIONS_REDUCED
}
)
print(f"lgbm params {lgbm_final.get_params()}")
print(f"cb params {cb_final.get_params()}")
MODEL_PARAMS = {
"LightGBM": lgbm_final,
"CatBoost": cb_final
}
# -------------------------
# 2. Configuración SFS/SFFS (Ajustada para ejecución exhaustiva)
# -------------------------
RESULTS_DIR = "results/features_auto_selection_7"
os.makedirs(RESULTS_DIR, exist_ok=True)
OUT_CSV = os.path.join(RESULTS_DIR, "features_auto_selection_sfs.csv")
# AJUSTES PARA 7 HORAS DE PROCESAMIENTO:
# 1. Sin prefiltro (None) o muy amplio (40) para permitir que SFFS encuentre interacciones complejas.
prefilter_k = None
n_splits = 3
max_features = 30
floating = True # True = SFFS (puede quitar features si sobran)
min_improvement = 1e-6
random_state = 42
Configurando modelos con parámetros óptimos previos...
lgbm params {'boosting_type': 'gbdt', 'class_weight': None, 'colsample_bytree': 1.0, 'importance_type': 'split', 'learning_rate': 0.1, 'max_depth': -1, 'min_child_samples': 20, 'min_child_weight': 0.001, 'min_split_gain': 0.0, 'n_estimators': 500, 'n_jobs': -1, 'num_leaves': 31, 'objective': None, 'random_state': 42, 'reg_alpha': 0.0, 'reg_lambda': 0.0, 'subsample': 1.0, 'subsample_for_bin': 200000, 'subsample_freq': 0, 'lgbm_n_estimators': 1800, 'lgbm_learning_rate': 0.018067869990858458, 'lgbm_num_leaves': 26, 'lgbm_min_child_samples': 30, 'lgbm_reg_alpha': 0.005330854069554935, 'lgbm_reg_lambda': 0.09987730382558391, 'lgbm_feature_fraction': 0.9170603878199113, 'lgbm_min_gain_to_split': 0.06584088996754021, 'eval_metric': 'average-precision', 'verbose': -1}
cb params {'iterations': 500, 'learning_rate': 0.02542971271126076, 'depth': 9, 'l2_leaf_reg': 4.4036714336001195, 'verbose': 0, 'random_strength': 0.863135324261114, 'eval_metric': 'PRAUC', 'allow_writing_files': False, 'subsample': 0.7158093626479908, 'random_state': 42, 'grow_policy': 'SymmetricTree'}
# -------------------------
# 5. Ejecución
# -------------------------
print("Iniciando SFFS exhaustivo...")
print(f"Output: {OUT_CSV}")
print("\nUsando dataset de entreno (90%):\n")
display(X_train_val.head(2))
print("\n")
final_selected, df_results = sfs_sffs(
X_train_val, y_train_val,
MODEL_PARAMS,
max_features=max_features,
floating=floating,
prefilter_k=prefilter_k,
n_splits=n_splits,
results_file=OUT_CSV
)
print("\n=== PROCESO TERMINADO ===")
print("Mejores features seleccionadas:", final_selected)
Iniciando SFFS exhaustivo... Output: results/features_auto_selection_7\features_auto_selection_sfs.csv Usando dataset de entreno (90%):
| canal | tipo_usuario | mes_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | total_clicks | clicks_por_sesion | sesiones_por_dia | usuarios_que_consultan_misma_primera_ficha | engagement_por_email | total_clicks_por_dia | clicks_si_fichas | recencia_ficha_unica | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 111955 | Directorios | PF | 8 | 1 | 1069 | 0 | 2 | 4 | 2.0 | 2.0 | 20 | 2.0 | 4.0 | 4.0 | 1069 |
| 64138 | Directorios | PF | 3 | 0 | 2306 | -1 | 2 | 3 | 1.5 | 2.0 | 1 | 1.5 | 3.0 | 0.0 | 0 |
============================================================ ✅ Cargando informacion: results/features_auto_selection_7\features_auto_selection_sfs.csv - Ultimo paso: 11 - Features: 9 -> ['total_fichas_consultadas', 'total_clicks', 'usuarios_que_consultan_misma_primera_ficha', 'canal', 'tipo_usuario', 'clicks_por_sesion', 'num_dias_sesiones', 'peer_signal_unica', 'bondad_email'] - Mejor Score Global: 0.43544 ============================================================ === PROCESO TERMINADO === Mejores features seleccionadas: ['total_fichas_consultadas', 'total_clicks', 'usuarios_que_consultan_misma_primera_ficha', 'canal', 'tipo_usuario', 'clicks_por_sesion', 'num_dias_sesiones', 'peer_signal_unica', 'bondad_email']
cols = [
'es_cliente',
'total_fichas_consultadas',
'total_clicks',
'usuarios_que_consultan_misma_primera_ficha',
'canal',
'tipo_usuario',
'clicks_por_sesion',
'num_dias_sesiones',
'peer_signal_unica',
'bondad_email'
]
df_cb_lgbm_sfss = df_cb_lgbm[cols]
Entreno y evaluacion del modelo
new_cat_cols =['canal', 'tipo_usuario', 'bondad_email']
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_cb_lgbm_sfss)
y_test_real, y_pred_final, y_pred_proba, pipeline_entrenado = construir_y_predecir_modelo_final(
X_train_val, y_train_val, X_test, y_test,
lgbm_best_params, cb_best_params, lr_best_params, new_cat_cols,
output_folder = 'results/final_model_fase8'
)
# 2. Evaluar
evaluar_modelo(y_test_real, y_pred_final, y_pred_proba)
# 3. Analizar
analizar_meta_estimator(pipeline_entrenado)
analizar_importancia_base(pipeline_entrenado, X_train_val.columns.tolist())
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada. ✔️ Modelo encontrado. Cargando desde: results/final_model_fase8\stacking_final_pipeline.joblib ✅ Usando modelo previamente entrenado. Generando predicciones en X_test... --- 📊 2.1. Métricas de Rendimiento Estándar --- | Métrica | Valor | |:----------|--------:| | Accuracy | 0.9556 | | Precision | 0.1344 | | Recall | 0.6212 | | F1-Score | 0.221 | | ROC AUC | 0.9165 | | PR AUC | 0.4572 | | FP Rate | 0.041 | --- 💰 2.2. Métricas de Negocio --- | KPI | Valor | |:--------------------|:--------| | Total Test | 19517 | | Clientes Reales (1) | 198 | | Detectados (TP) | 123 | | Perdidos (FN) | 75 | | Falsas Alarmas (FP) | 792 | | Tasa de Captura | 62.12% | --- 💰 2.3. Curvas de Negocio (Lift y Gain) --- | Decil | %_Poblacion | Clientes_Acumulados | Gain_Acumulado | Lift_Acumulado | Tasa_Base | |--------:|:--------------|----------------------:|:-----------------|-----------------:|------------:| | 1 | 10% | 151 | 76.3% | 7.63 | 0.0101 | | 2 | 20% | 172 | 86.9% | 4.34 | 0.0101 | | 3 | 30% | 180 | 90.9% | 3.03 | 0.0101 | | 4 | 40% | 184 | 92.9% | 2.32 | 0.0101 | | 5 | 50% | 190 | 96.0% | 1.92 | 0.0101 | | 6 | 60% | 195 | 98.5% | 1.64 | 0.0101 | | 7 | 70% | 198 | 100.0% | 1.43 | 0.0101 | | 8 | 80% | 198 | 100.0% | 1.25 | 0.0101 | | 9 | 90% | 198 | 100.0% | 1.11 | 0.0101 | | 10 | 100% | 198 | 100.0% | 1 | 0.0101 | **Interpretación de Puntos Clave:** * 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **76.3%** de todos los clientes reales. * 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **7.63** en el primer decil significa que la tasa de conversión en ese grupo es **7.63 veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).
--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador --- El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base: | Modelo Base | Peso (Coef) | Peso Absoluto | |:--------------|--------------:|----------------:| | cb | 20.1238 | 20.1238 | | lgbm | 13.0395 | 13.0395 |
--- 🌳 3.2. Importancia de Variables (Nivel 1) --- INFO: Estimador CatBoost encontrado usando el alias 'cb'. Importancia de TODAS las Variables (Normalizada y Absoluta): | Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia | |:-------------------------------------------|-----------:|---------:|----------------:|--------------:|---------------:|-----------------:| | total_clicks | 617 | 12.889 | 1 | 0.379486 | 0.689743 | 0.620514 | | clicks_por_sesion | 600 | 13.4266 | 0.969424 | 0.400221 | 0.684823 | 0.569203 | | bondad_email | 61 | 28.9762 | 0 | 1 | 0.5 | -1 | | total_fichas_consultadas | 495 | 8.39938 | 0.780576 | 0.206313 | 0.493444 | 0.574262 | | usuarios_que_consultan_misma_primera_ficha | 514 | 5.40827 | 0.814748 | 0.0909405 | 0.452844 | 0.723808 | | canal | 234 | 16.2611 | 0.311151 | 0.509554 | 0.410353 | -0.198403 | | peer_signal_unica | 207 | 4.84149 | 0.26259 | 0.0690788 | 0.165834 | 0.193511 | | tipo_usuario | 126 | 6.74741 | 0.116906 | 0.142593 | 0.12975 | -0.025687 | | num_dias_sesiones | 146 | 3.05058 | 0.152878 | 0 | 0.0764388 | 0.152878 |
📌 Conclusiones
Los resultados obtenidos indican que el proceso de selección automática de variables no aporta mejoras relevantes en el rendimiento del modelo en este contexto. El método empleado presenta limitaciones para capturar adecuadamente las interacciones no lineales y sinergias complejas entre predictores, especialmente en una arquitectura de tipo stacking basada en modelos de gradient boosting. Asimismo, su aplicación con configuraciones exigentes en términos de validación y métrica implica un coste computacional elevado, lo que reduce su utilidad práctica frente a enfoques alternativos.
No obstante, este análisis ha resultado útil como herramienta exploratoria, al confirmar de forma consistente la relevancia de un conjunto reducido de variables clave —principalmente aquellas asociadas al comportamiento del usuario y a la interacción con contenidos—. En consecuencia, la selección final de predictores se apoya en el análisis de importancias derivado del modelado final, que ofrece una visión más robusta y alineada con el desempeño observado sobre el conjunto de prueba.
En el siguiente y último ciclo se procederá a evaluar de manera sistemática las optimizaciones más prometedoras identificadas hasta este punto y a consolidar el modelo definitivo.
Ciclo 3: Consolidación del Dataset Final y Ajuste Definitivo del Modelo
El tercer y último ciclo del proceso de modelado tiene como objetivo la consolidación definitiva del sistema predictivo. En esta fase se construye el dataset final, incorporando únicamente las variables y transformaciones que han demostrado aportar valor de forma consistente en los ciclos anteriores, priorizando robustez, interpretabilidad y estabilidad en generalización.
A partir de este conjunto de datos depurado, se realiza una tercera y última iteración de optimización de hiperparámetros, centrada en un ajuste fino del espacio de búsqueda previamente acotado. Este proceso no persigue una exploración exhaustiva, sino una mejora incremental orientada a consolidar los valores óptimos identificados en fases anteriores.
Finalmente, se lleva a cabo una evaluación comparativa sobre el conjunto de prueba, analizando el rendimiento de los modelos base (CatBoost y LightGBM) bajo las distintas configuraciones de hiperparámetros obtenidas a lo largo de los tres ciclos. Este análisis permite validar la estabilidad de los resultados, descartar configuraciones subóptimas y seleccionar de manera informada la arquitectura y parametrización definitivas que alimentarán el modelo final de stacking.
Dataset definitivo
cat_cols = ['tipo_usuario', 'canal']
categorical_features = cat_cols
delete_cols= [
#'Engagement_por_Email',
'log_total_fichas_consultadas',
'log_usuarios_que_consultan_misma_primera_ficha',
'email_bueno_finde',
'PF_finde',
'flag_ficha_unica',
'es_finde_registro',
'sesiones_finde',
'log_antiguedad_real',
'clicks_despues_registro_finde',#Devuelvela 15->16
'fichas_consultadas_si_tiene',
'tiene_fichas', #Señal mejor capturada con las combinadas
'peer_signal_si_fichas',
'peer_signal_unica',
'fichas_y_email_bueno', #Penaliza demasiado a LGBM y su informacion esta en engagement_por_email
'num_dias_sesiones', #Penaliza demasiado a CatBoost
'dia_semana_registro',
'bondad_email',
]
df_final_cb_lgbm = df_cb_lgbm.drop(columns=delete_cols)
# Asegúrate de que X_train_val, y_train_val, etc. están cargados
# y que lr_best_params tiene el formato correcto (ej. {'lr_C': 0.1, ...})
X_train_val, X_test, y_train_val, y_test = create_stratified_splits(df_final_cb_lgbm)
--- Iniciando división de datos (195165 registros) target col y variable estratificada = "es_cliente" random state = 42, test size = 10.0% --- --- Distribución de Clases (Verificación) --- Original (195165): es_cliente 0 98.98% 1 1.02% Train/Validation (175648): es_cliente 0 98.98% 1 1.02% Test (19517): es_cliente 0 98.99% 1 1.01% ✅ Verificación: La división fue exitosa y estratificada.
guardar_csv(df_final_cb_lgbm, "src/datasets_stacking", "df_final_cb_lgbm.csv")
✅ df_final_cb_lgbm.csv guardado en src/datasets_stacking\df_final_cb_lgbm.csv
Optimizacion de hiperarámetros
# Redefinimos parametros:
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
def get_params_and_model(model_name, trial, categorical_features=None, random_state=42):
"""
Define el espacio de búsqueda de Optuna y devuelve los parámetros y el modelo.
El parámetro 'categorical_features' es opcional (por defecto es None).
Solo se usará si se proporciona al inicializar LGBM o CatBoost.
"""
if model_name == 'LGBM':
print("🛠️ Optuna: Configurando LightGBM (LGBM)...")
params = {
'n_estimators': trial.suggest_int('n_estimators', 1700, 1900, step=50),
'learning_rate': trial.suggest_float('learning_rate', 0.008, 0.02, log=True),
'num_leaves': trial.suggest_int('num_leaves', 20, 60),
'min_child_samples': trial.suggest_int('min_child_samples', 20, 50),
'reg_alpha': trial.suggest_float('reg_alpha', 0.004, 0.01, log=True),
'reg_lambda': trial.suggest_float('reg_lambda', 0.004, 0.10, log=True),
'feature_fraction': trial.suggest_float('feature_fraction', 0.85, 1.0),
'boosting_type': trial.suggest_categorical('boosting_type', ['dart']),
'min_gain_to_split' : trial.suggest_float('min_gain_to_split', 0.01, 0.2)
}
# Diccionario para parámetros adicionales
lgbm_args = {
'random_state': random_state,
'eval_metric': 'average-precision',
'n_jobs': -1,
'verbose': -1,
# Añadir categóricas si existen
**({'categorical_feature': categorical_features} if categorical_features is not None else {})
}
model = LGBMClassifier(**params, **lgbm_args)
elif model_name == 'CatBoost':
print("🛠️ Optuna: Configurando CatBoost (CB)...")
params = {
'iterations': trial.suggest_int('iterations', 750, 1400, step=50),
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.03, log=True),
'depth': trial.suggest_int('depth', 5, 10),
'l2_leaf_reg': trial.suggest_float('l2_leaf_reg', 0.1, 5.0),
'subsample': trial.suggest_float('subsample', 0.65, 0.85),
'random_strength': trial.suggest_float('random_strength', 0.5, 5, log=True),
'grow_policy': trial.suggest_categorical('grow_policy', ['SymmetricTree'])
}
# Diccionario para parámetros adicionales de CatBoost
catboost_args = {
'eval_metric':'PRAUC',
'random_state': random_state,
'verbose': 0,
'thread_count': -1,
'allow_writing_files': False,
# Añadir categóricas si existen (¡OJO: 'cat_features' PLURAL!)
**({'cat_features': categorical_features} if categorical_features is not None else {})
}
model = CatBoostClassifier(**params, **catboost_args)
elif model_name == 'LR_META':
print("🛠️ Optuna: Configurando Logistic Regression (Meta)...")
penalty = trial.suggest_categorical('lr_penalty', ['l1', 'l2'])
if penalty == 'l1':
# Para L1, solo liblinear funciona bien
solver = 'liblinear'
else: # penalty == 'l2'
# Para L2, varios solvers funcionan
solver = trial.suggest_categorical('lr_solver', ['lbfgs', 'newton-cg'])
lr_params = {
'C': trial.suggest_float('lr_C', 0.001, 0.1, log=True),
'penalty': trial.suggest_categorical('lr_penalty', ['l1', 'l2']),
'solver': solver,
'random_state': random_state,
'max_iter': 1000, # Añadir para asegurar convergencia
}
params = lr_params.copy()
# Crear el modelo de Logistic Regression
model = LogisticRegression(class_weight='balanced',**lr_params)
else:
raise ValueError(f"Modelo no soportado: {model_name}. Use 'LGBM', 'CatBoost' o 'LR_META'.")
return params, model
print(f"Started optimization on dataset:\n")
print(f"Categorical categories = {cat_cols}")
display(X_train_val.head(1))
lgbm_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='LGBM', categorical_features=cat_cols, folder_name="results/tuning/params_fase25", n_trials=50)
cb_best_params, _ = run_base_tuning(X_train_val, y_train_val, model_name='CatBoost', categorical_features = cat_cols, folder_name="results/tuning/params_fase25", n_trials=75)
# 2. Generar (o cargar) Features de Nivel 2
X_level2 = generate_oof_predictions(X_train_val, y_train_val, lgbm_best_params, cb_best_params, cat_cols, "results/oof_features_fase25")
# 3. Optimizar (o cargar) el Meta-Estimador
lr_best_params, lr_best_score = run_meta_tuning(X_level2, y_train_val,folder_name="results/tuning/params_fase25", n_trials=50)
print("\n--- RESUMEN DE MEJORES PARÁMETROS ---")
print(f"LGBM: {lgbm_best_params}")
print(f"CatBoost: {cb_best_params}")
print(f"LR Meta: {lr_best_params}")
Started optimization on dataset: Categorical categories = ['tipo_usuario', 'canal']
| canal | tipo_usuario | mes_registro | total_fichas_consultadas | recencia_fichas | antiguedad_comportamiento_fichas | total_sesiones | total_clicks | clicks_por_sesion | sesiones_por_dia | usuarios_que_consultan_misma_primera_ficha | engagement_por_email | total_clicks_por_dia | clicks_si_fichas | recencia_ficha_unica | Engagement_por_Email | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 111955 | Directorios | PF | 8 | 1 | 1069 | 0 | 2 | 4 | 2.0 | 2.0 | 20 | 2.0 | 4.0 | 4.0 | 1069 | 0.0 |
✔️ Cargando parámetros desde results/tuning/params_fase25\lgbm_best_params.json
🎉 Resultados cargados para LGBM. No se requiere re-optimizar.
✔️ Cargando parámetros desde results/tuning/params_fase25\catboost_best_params.json
🎉 Resultados cargados para CatBoost. No se requiere re-optimizar.
✅ Resultados previos de OOF encontrados en results/oof_features_fase25\X_level2_oof.csv.
Cargando y devolviendo resultados existentes...
✔️ Cargando parámetros desde results/tuning/params_fase25\lr_meta_best_params.json
🎉 Resultados cargados para Logistic Regression (Meta-Estimador).
--- RESUMEN DE MEJORES PARÁMETROS ---
LGBM: {'n_estimators': 1850, 'learning_rate': 0.019670117069306953, 'num_leaves': 20, 'min_child_samples': 24, 'reg_alpha': 0.006353187509196037, 'reg_lambda': 0.04403392556093363, 'feature_fraction': 0.9759638001537078, 'boosting_type': 'dart', 'min_gain_to_split': 0.1518131467380639}
CatBoost: {'iterations': 1350, 'learning_rate': 0.016587278402388345, 'depth': 8, 'l2_leaf_reg': 4.470993129950294, 'subsample': 0.7455661547105437, 'random_strength': 0.6930865682636139, 'grow_policy': 'SymmetricTree'}
LR Meta: {'lr_penalty': 'l2', 'lr_solver': 'lbfgs', 'lr_C': 0.05715999698461381}
Análisis comparativo de parametrizaciones
cat_cols = ['tipo_usuario', 'canal']
categorical_features = cat_cols
delete_cols= [
'Engagement_por_Email',
'log_total_fichas_consultadas',
'log_usuarios_que_consultan_misma_primera_ficha',
'email_bueno_finde',
'PF_finde',
'flag_ficha_unica',
'es_finde_registro',
'sesiones_finde',
'log_antiguedad_real',
'clicks_despues_registro_finde',#Devuelvela 15->16
'fichas_consultadas_si_tiene',
'tiene_fichas', #Señal mejor capturada con las combinadas
'peer_signal_si_fichas',
'peer_signal_unica',
'fichas_y_email_bueno', #Penaliza demasiado a LGBM y su informacion esta en engagement_por_email
'num_dias_sesiones', #Penaliza demasiado a CatBoost
'dia_semana_registro',
'bondad_email',
]
df_final_cb_lgbm = df_cb_lgbm.drop(columns=delete_cols)
import re
def load_pr_auc(results_filename: str) -> float | None:
"""
Carga el valor de PR-AUC de un archivo de resultados con un formato específico.
El archivo debe contener una línea en el formato: "PR-AUC: X.XXXX\n"
Args:
results_filename (str): La ruta completa del archivo de resultados.
Returns:
float | None: El valor del PR-AUC como número flotante, o None si no se encuentra.
"""
if not os.path.exists(results_filename):
print(f"ADVERTENCIA: El archivo no existe en la ruta: {results_filename}")
return None
# El patrón regex busca:
# 1. 'PR-AUC: ' (la etiqueta fija)
# 2. (\d+\.\d+) (uno o más dígitos, seguido de un punto, seguido de uno o más dígitos, que es nuestro grupo de captura)
# 3. La línea puede terminar después.
# El uso de 're.IGNORECASE' permite capturar 'pr-auc' o 'PR-AUC'.
pr_auc_pattern = re.compile(r"PR-AUC:\s*(\d+\.\d+)", re.IGNORECASE)
try:
with open(results_filename, 'r') as f:
for line in f:
match = pr_auc_pattern.search(line)
if match:
# El grupo 1 ([1]) es el valor numérico capturado
pr_auc_value = float(match.group(1))
print(f"✅ Éxito: PR-AUC encontrado y cargado: {pr_auc_value:.4f}")
return pr_auc_value
# Si terminamos el bucle sin encontrar el patrón
print(f"ADVERTENCIA: No se encontró la etiqueta 'PR-AUC:' en el archivo {results_filename}.")
return None
except Exception as e:
print(f"ERROR: No se pudo leer el archivo {results_filename}. Error: {e}")
return None
def train_and_evaluate_model(
df: pd.DataFrame,
params: dict,
model_name: str,
output_path: str
) -> float:
"""
Entrena un modelo LightGBM o CatBoost, calcula el PR-AUC y guarda el modelo y resultados.
Args:
df (pd.DataFrame): DataFrame que contiene las features y la variable 'es_cliente'.
params (dict): Diccionario de parámetros para el modelo especificado.
model_name (str): Nombre del modelo ('LightGBM' o 'CatBoost').
output_path (str): Directorio donde guardar el modelo y los resultados.
Returns:
float: El valor del PR-AUC obtenido en el conjunto de test.
"""
# 1. Preparación y División de Datos
# Asegurarse de que la carpeta de salida exista
os.makedirs(output_path, exist_ok=True)
filename= os.path.join(output_path, f'{model_name}_results.txt')
if os.path.exists(filename):
pr_auc = load_pr_auc(filename)
return pr_auc
TARGET_VAR = 'es_cliente'
if TARGET_VAR not in df.columns:
raise ValueError(f"El DataFrame debe contener la variable target: '{TARGET_VAR}'")
X = df.drop(columns=[TARGET_VAR])
y = df[TARGET_VAR]
# Separar 10% para test (seed=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.10, random_state=42, stratify=y
)
print(f"--- Iniciando entrenamiento de {model_name} ---")
#print(f"Tamaño de Entrenamiento: {len(X_train)} | Tamaño de Test: {len(X_test)}")
# 2. Inicialización y Entrenamiento del Modelo
model = None
if model_name == 'LightGBM':
# LightGBM necesita que la target sea numérica (0/1)
lgb_train = lgb.Dataset(X_train, y_train)
# Parámetros obligatorios para la clasificación binaria si no están en `params`
params.setdefault('objective', 'binary')
#params.setdefault('metric', 'auc')
#params.setdefault('boosting_type', 'gbdt')
# Entrenar
model = lgb.train(
params,
lgb_train,
num_boost_round=params.get('n_estimators', 100)
)
elif model_name == 'CatBoost':
# CatBoost es más flexible con los parámetros y maneja categóricas automáticamente
# (aunque aquí asumimos que ya están codificadas o especificadas en `params['cat_features']`)
model = cb.CatBoostClassifier(
**params,
verbose=0, # Desactivar salida de entrenamiento
)
model.fit(X_train, y_train)
else:
raise ValueError("model_name debe ser 'LightGBM' o 'CatBoost'")
# 3. Predicción de Probabilidades
# Obtener la probabilidad de ser la clase positiva (Clase 1)
if model_name == 'CatBoost':
# CatBoost devuelve las probabilidades para ambas clases, necesitamos la columna [:, 1]
y_prob = model.predict_proba(X_test)[:, 1]
else: # LightGBM
# LightGBM devuelve directamente la probabilidad de la clase positiva
y_prob = model.predict(X_test)
# 4. Cálculo del PR-AUC
precisions, recalls, _ = precision_recall_curve(y_test, y_prob)
pr_auc = auc(recalls, precisions)
print(f"PR-AUC obtenido: {pr_auc:.4f}\n")
# 5. Guardar Resultados
# Guardar el modelo entrenado
# model_filename = os.path.join(output_path, f'{model_name}_model.pkl')
# Guardar el resultado del PR-AUC
results_filename = os.path.join(output_path, f'{model_name}_results.txt')
with open(results_filename, 'w') as f:
f.write(f"Modelo: {model_name}\n")
f.write(f"PR-AUC: {pr_auc:.4f}\n")
f.write("Parámetros utilizados:\n")
for k, v in params.items():
f.write(f" {k}: {v}\n")
return pr_auc
cat_cols = ['tipo_usuario', 'canal']
params0_lgbm ={
"n_estimators": 1200,
"learning_rate": 0.031483861784621175,
"num_leaves": 10,
"max_depth": 8,
"min_child_samples": 56,
"reg_alpha": 0.001034762180977269,
"reg_lambda": 0.002758894735443576,
"eval_metric": "average-precision",
'random_state': 42,
'categorical_feature': cat_cols
}
params1_lgbm={
"n_estimators": 1800,
"learning_rate": 0.018067869990858458,
"num_leaves": 26,
"min_child_samples": 30,
"reg_alpha": 0.005330854069554935,
"reg_lambda": 0.09987730382558391,
"feature_fraction": 0.9170603878199113,
"min_gain_to_split": 0.06584088996754021,
"boosting_type": "dart",
'random_state': 42,
"eval_metric": "average-precision",
'categorical_feature': cat_cols
}
params2_lgbm = {
"n_estimators": 1850,
"learning_rate": 0.019670117069306953,
"num_leaves": 20,
"min_child_samples": 24,
"reg_alpha": 0.006353187509196037,
"reg_lambda": 0.04403392556093363,
"feature_fraction": 0.9759638001537078,
"boosting_type": "dart",
"min_gain_to_split": 0.1518131467380639,
'random_state': 42,
"eval_metric": "average-precision",
'categorical_feature': cat_cols
}
params0_cb={
"iterations": 1200,
"learning_rate": 0.010043418192921075,
"depth": 8,
"l2_leaf_reg": 1.4123243282572246,
"subsample": 0.6113633312351796,
"min_data_in_leaf": 25,
"eval_metric": "PRAUC",
'random_state': 42,
'cat_features':cat_cols
}
params1_cb = {
"iterations": 850,
"learning_rate": 0.02542971271126076,
"depth": 9,
"l2_leaf_reg": 4.4036714336001195,
"subsample": 0.7158093626479908,
"random_strength": 0.863135324261114,
"grow_policy": "SymmetricTree",
'random_state': 42,
"eval_metric": "PRAUC",
'cat_features':cat_cols
}
params2_cb = {
'iterations': 950,
'learning_rate': 0.013552589177947974,
'depth': 9,
'l2_leaf_reg': 2.8914705041133106,
'subsample': 0.693879965975925,
'random_strength': 1.2178056597738505,
'grow_policy': 'SymmetricTree',
'random_state': 42,
"eval_metric": "PRAUC",
'cat_features':cat_cols
}
model_params={
"LightGBM": [params0_lgbm, params1_lgbm, params2_lgbm],
"CatBoost": [params0_cb, params1_cb, params2_cb]
}
for model, params in model_params.items():
for i, param in enumerate(params):
tit(f"Comprobacion parametros FASE {i} de {model}")
out_path = f"results\\tuning\\modelCheck\\{model}\\Fase_{i+1}"
train_and_evaluate_model(
df_final_cb_lgbm,
param,
model,
out_path
)
==================================================================================================== Comprobacion parametros FASE 0 de LightGBM ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4558 ==================================================================================================== Comprobacion parametros FASE 1 de LightGBM ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4696 ==================================================================================================== Comprobacion parametros FASE 2 de LightGBM ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4713 ==================================================================================================== Comprobacion parametros FASE 0 de CatBoost ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4677 ==================================================================================================== Comprobacion parametros FASE 1 de CatBoost ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4730 ==================================================================================================== Comprobacion parametros FASE 2 de CatBoost ==================================================================================================== ✅ Éxito: PR-AUC encontrado y cargado: 0.4705
Evaluación definitiva del modelo
En esta última fase se entrenó y evaluó sobre el conjunto de prueba la mejor configuración global identificada a lo largo de los ciclos de optimización. Aunque se exploró una tercera optimización específica de hiperparámetros para CatBoost, los resultados mostraron un empeoramiento del rendimiento, confirmando que la configuración óptima no correspondía a la iteración más reciente, sino a una parametrización obtenida en fases anteriores.
Esta evaluación final permite consolidar la selección del modelo definitivo, priorizando estabilidad, capacidad de generalización y métricas alineadas con los objetivos de negocio.
def train_stacking_ensemble(
df: pd.DataFrame,
lgbm_params: dict,
cb_params: dict,
lr_params: dict,
output_path: str,
cv:int = 5
):
"""
Entrena un modelo Stacking (LightGBM + CatBoost con Meta-Learner Logistic Regression).
Args:
df (pd.DataFrame): DataFrame que contiene las features y la variable 'es_cliente'.
lgbm_params (dict): Parámetros para el LightGBM Base Learner.
cb_params (dict): Parámetros para el CatBoost Base Learner.
lr_params (dict): Parámetros para el Logistic Regression Meta-Learner.
output_path (str): Directorio donde guardar el modelo y los resultados.
Returns:
tuple: (y_test, y_pred, y_pred_proba, final_model)
"""
# 1. Preparación y División de Datos
# Asegurarse de que la carpeta de salida exista
os.makedirs(output_path, exist_ok=True)
TARGET_VAR = 'es_cliente'
if TARGET_VAR not in df.columns:
raise ValueError(f"El DataFrame debe contener la variable target: '{TARGET_VAR}'")
X = df.drop(columns=[TARGET_VAR])
y = df[TARGET_VAR]
# Separar 10% para test (seed=42, estratificado)
X_train_val, X_test, y_train_val, y_test = train_test_split(
X, y, test_size=0.10, random_state=42, stratify=y
)
print(f"--- Stacking Ensemble Iniciado ---")
print(f"Tamaño de Entrenamiento/Validación: {len(X_train_val)} | Tamaño de Test: {len(X_test)}")
# 2. Definición de Base Learners y Meta-Learner
# Base Learner 1: LightGBM
# Importante: Usar LGBMClassifier, no lgb.train, para la API de StackingClassifier
# Configuramos para predecir probabilidades (ya que StackingClassifier hace esto por defecto)
lgbm_learner = lgb.LGBMClassifier(
**lgbm_params,
n_jobs=-1,
# Necesario si no se pasa en params, para que el fit sea consistente
objective='binary',
)
# Base Learner 2: CatBoost
cb_learner = cb.CatBoostClassifier(
**cb_params,
verbose=0, # Silenciar el output del fit
# Necesario si no se pasa en params
loss_function='Logloss'
)
# Inicializar el objeto
meta_learner = LogisticRegression(
**lr_params
)
# 3. Creación del Stacking Classifier
estimators = [
('lgbm', lgbm_learner),
('catboost', cb_learner)
]
final_model = StackingClassifier(
estimators=estimators,
final_estimator=meta_learner,
cv=cv, # Cross-validation de 5 folds (por defecto solicitado)
n_jobs=-1,
passthrough=True # No pasar features originales al meta-learner (por defecto)
)
# 4. Entrenamiento
print("Iniciando entrenamiento con CV=5...")
final_model.fit(X_train_val, y_train_val)
print("Entrenamiento completado.")
# 5. Predicción y Evaluación
# Predicciones binarias (usando umbral por defecto de 0.5 del meta-learner)
y_pred = final_model.predict(X_test)
# Predicciones de probabilidad para PR-AUC
y_pred_proba = final_model.predict_proba(X_test)[:, 1]
# Cálculo del PR-AUC
precisions, recalls, _ = precision_recall_curve(y_test, y_pred_proba)
pr_auc = auc(recalls, precisions)
print(f"--- Evaluación Final del Stacking Ensemble ---")
print(f"PR-AUC resultante: {pr_auc:.4f}")
# 6. Guardar Resultados
# Guardar el modelo entrenado
model_filename = os.path.join(output_path, 'stacking_model.pkl')
with open(model_filename, 'wb') as f:
pickle.dump(final_model, f)
print(f"Modelo guardado en: {model_filename}")
# Guardar el resultado del PR-AUC
results_filename = os.path.join(output_path, 'stacking_results.txt')
with open(results_filename, 'w') as f:
f.write(f"Modelo: Stacking (LGBM + CatBoost + LR)\n")
f.write(f"PR-AUC: {pr_auc:.4f}\n")
print(f"Resultados guardados en: {results_filename}")
# 7. Retornar resultados
return y_test, y_pred, y_pred_proba, final_model
params1_lgbm={
"n_estimators": 1800,
"learning_rate": 0.018067869990858458,
"num_leaves": 26,
"min_child_samples": 30,
"reg_alpha": 0.005330854069554935,
"reg_lambda": 0.09987730382558391,
"feature_fraction": 0.9170603878199113,
"min_gain_to_split": 0.06584088996754021,
"boosting_type": "dart",
'random_state': 42,
"eval_metric": "prauc",
'categorical_feature': cat_cols
}
params2_lgbm = {
"n_estimators": 1850,
"learning_rate": 0.019670117069306953,
"num_leaves": 20,
"min_child_samples": 24,
"reg_alpha": 0.006353187509196037,
"reg_lambda": 0.04403392556093363,
"feature_fraction": 0.9759638001537078,
"boosting_type": "dart",
"min_gain_to_split": 0.1518131467380639,
'random_state': 42,
"eval_metric": "average-precision",
'categorical_feature': cat_cols
}
params1_cb = {
"iterations": 850,
"learning_rate": 0.02542971271126076,
"depth": 9,
"l2_leaf_reg": 4.4036714336001195,
"subsample": 0.7158093626479908,
"random_strength": 0.863135324261114,
"grow_policy": "SymmetricTree",
'random_state': 42,
"eval_metric": "PRAUC",
'cat_features':cat_cols
}
lr_params2={
"penalty": "l2",
"solver": "lbfgs",
"C": 0.09946667091710057,
'class_weight':'balanced'
}
OUTPUT_DIR = 'CatBoost_LightGBM_MetaLogR_Stacking'
#y_test, y_pred, y_pred_proba, model = train_stacking_ensemble(
# df=df_final_cb_lgbm,
# lgbm_params=params1_lgbm,
# cb_params=params1_cb,
# lr_params=lr_params2,
# output_path=OUTPUT_DIR)
#y_test, y_pred, y_pred_proba, model = train_stacking_ensemble(
# df=df_final_cb_lgbm,
# lgbm_params=params2_lgbm,
# cb_params=params1_cb,
# lr_params=lr_params2,
# output_path=OUTPUT_DIR)
# 1. Definir la ruta completa del archivo
# Usamos os.path.join para asegurar la compatibilidad con diferentes sistemas operativos
file_path = os.path.join('CatBoost_LightGBM_MetaLogR_Stacking', 'stacking_model.pkl')
# 2. Cargar el modelo usando pickle
try:
with open(file_path, 'rb') as file:
model = pickle.load(file)
print(f"✅ Modelo cargado exitosamente desde: {file_path}")
print(f"Tipo de objeto cargado: {type(model)}")
except FileNotFoundError:
print(f"❌ ERROR: No se encontró el archivo en la ruta: {file_path}")
except Exception as e:
print(f"❌ ERROR al cargar el modelo: {e}")
X = df_final_cb_lgbm.drop(columns=['es_cliente'])
y = df_final_cb_lgbm['es_cliente']
# Separar 10% para test (seed=42, estratificado)
X_train_val, X_test, y_train_val, y_test = train_test_split(
X, y, test_size=0.10, random_state=42, stratify=y
)
y_pred = model.predict(X_test)
# Predicciones de probabilidad para PR-AUC
y_pred_proba = model.predict_proba(X_test)[:, 1]
✅ Modelo cargado exitosamente desde: CatBoost_LightGBM_MetaLogR_Stacking\stacking_model.pkl Tipo de objeto cargado: <class 'sklearn.ensemble._stacking.StackingClassifier'>
evaluar_modelo(y_test, y_pred, y_pred_proba)
feature_names = df_final_cb_lgbm.drop(columns=["es_cliente"]).columns.tolist()
# 3. Analizar
analizar_meta_estimator(model)
analizar_importancia_base(model, feature_names = feature_names)
--- 📊 2.1. Métricas de Rendimiento Estándar --- | Métrica | Valor | |:----------|--------:| | Accuracy | 0.9506 | | Precision | 0.1267 | | Recall | 0.6566 | | F1-Score | 0.2124 | | ROC AUC | 0.9268 | | PR AUC | 0.4772 | | FP Rate | 0.0464 | --- 💰 2.2. Métricas de Negocio --- | KPI | Valor | |:--------------------|:--------| | Total Test | 19517 | | Clientes Reales (1) | 198 | | Detectados (TP) | 130 | | Perdidos (FN) | 68 | | Falsas Alarmas (FP) | 896 | | Tasa de Captura | 65.66% | --- 💰 2.3. Curvas de Negocio (Lift y Gain) --- | Decil | %_Poblacion | Clientes_Acumulados | Gain_Acumulado | Lift_Acumulado | Tasa_Base | |--------:|:--------------|----------------------:|:-----------------|-----------------:|------------:| | 1 | 10% | 152 | 76.8% | 7.68 | 0.0101 | | 2 | 20% | 174 | 87.9% | 4.39 | 0.0101 | | 3 | 30% | 183 | 92.4% | 3.08 | 0.0101 | | 4 | 40% | 188 | 94.9% | 2.37 | 0.0101 | | 5 | 50% | 191 | 96.5% | 1.93 | 0.0101 | | 6 | 60% | 197 | 99.5% | 1.66 | 0.0101 | | 7 | 70% | 198 | 100.0% | 1.43 | 0.0101 | | 8 | 80% | 198 | 100.0% | 1.25 | 0.0101 | | 9 | 90% | 198 | 100.0% | 1.11 | 0.0101 | | 10 | 100% | 198 | 100.0% | 1 | 0.0101 | **Interpretación de Puntos Clave:** * 📈 **Gain (Ganancia):** Muestra el porcentaje de clientes (positivos) que se capturan al alcanzar un porcentaje de la población. Si contactamos al 10% más propenso (Decil 1), capturamos al **76.8%** de todos los clientes reales. * 🚀 **Lift (Elevación):** Mide cuánto mejor es el modelo que una selección aleatoria. Un valor de **7.68** en el primer decil significa que la tasa de conversión en ese grupo es **7.68 veces** superior a la tasa de conversión promedio de toda la población (Tasa Base).
--- ⚖️ 3.1. Importancia y Dirección en el Meta-Estimador --- El Meta-Estimador (Regresión Logística) pondera las predicciones de los modelos base: | Modelo Base | Peso (Coef) | Peso Absoluto | |:--------------|--------------:|----------------:| | lgbm | 20.7576 | 20.7576 | | catboost | 14.4653 | 14.4653 |
--- 🌳 3.2. Importancia de Variables (Nivel 1) --- INFO: Estimador CatBoost encontrado usando el nombre 'catboost'. Importancia de TODAS las Variables (Normalizada y Absoluta): | Feature | LGBM_Imp | CB_Imp | LGBM_Imp_Norm | CB_Imp_Norm | Imp Promedio | Imp Diferencia | |:-------------------------------------------|-----------:|---------:|----------------:|--------------:|---------------:|-----------------:| | engagement_por_email | 3425 | 24.2434 | 0.393784 | 1 | 0.696892 | -0.606216 | | total_clicks | 7560 | 7.39645 | 1 | 0.273312 | 0.636656 | 0.726688 | | total_fichas_consultadas | 6263 | 6.46916 | 0.809852 | 0.233314 | 0.521583 | 0.576538 | | canal | 3317 | 15.442 | 0.37795 | 0.620352 | 0.499151 | -0.242402 | | usuarios_que_consultan_misma_primera_ficha | 3753 | 5.66669 | 0.441871 | 0.1987 | 0.320285 | 0.243171 | | clicks_por_sesion | 3069 | 7.40875 | 0.341592 | 0.273843 | 0.307718 | 0.067749 | | recencia_fichas | 3233 | 4.46326 | 0.365636 | 0.146791 | 0.256213 | 0.218845 | | total_sesiones | 3301 | 2.8488 | 0.375605 | 0.0771513 | 0.226378 | 0.298453 | | tipo_usuario | 2307 | 5.502 | 0.229878 | 0.191596 | 0.210737 | 0.0382822 | | sesiones_por_dia | 1955 | 4.83125 | 0.178273 | 0.162664 | 0.170468 | 0.0156093 | | mes_registro | 1326 | 6.55909 | 0.0860578 | 0.237193 | 0.161626 | -0.151136 | | total_clicks_por_dia | 2127 | 2.62803 | 0.203489 | 0.0676286 | 0.135559 | 0.135861 | | recencia_ficha_unica | 1671 | 3.02286 | 0.136637 | 0.0846593 | 0.110648 | 0.0519776 | | clicks_si_fichas | 739 | 2.45808 | 0 | 0.0602979 | 0.0301489 | -0.0602979 | | antiguedad_comportamiento_fichas | 954 | 1.06018 | 0.0315203 | 0 | 0.0157602 | 0.0315203 |
📌 Conclusiones
Los resultados obtenidos confirman que el tercer ciclo de optimización mediante Optuna no aportó mejoras sustantivas sobre el rendimiento del sistema. Por el contrario, la combinación de la selección final de variables junto con la optimización de hiperparámetros alcanzada en el segundo ciclo constituye la configuración más eficiente y estable. Este comportamiento sugiere que, a partir de cierto punto, una exploración adicional del espacio de búsqueda introduce rendimientos decrecientes e incluso puede degradar la capacidad de generalización del modelo.
Un aspecto especialmente relevante de esta fase final es el cambio en el equilibrio de influencia entre los modelos base. Mientras que en configuraciones previas CatBoost ejercía un mayor peso dentro del esquema de stacking, en el modelo final es LightGBM el que pasa a dominar la decisión del meta-estimador. Este desplazamiento resulta coherente con la evolución del sistema, ya que las optimizaciones sucesivas han reforzado de forma progresiva el recall frente a la precision, ámbito en el que LightGBM muestra un comportamiento especialmente competitivo.
Las mejoras acumuladas a lo largo de todo el proceso de optimización se resumen en la siguiente tabla:
| Métrica Clave | Resultado Inicial | Resultado Final | Cambio | Impacto |
|---|---|---|---|---|
| PR AUC | 0.4429 | 0.4772 | +7.74% | Incremento significativo del poder discriminatorio sobre la clase minoritaria. |
| Lift (Decil 1) | 7.57 | 7.68 | +1.45% | Mayor eficiencia en la focalización del 10% más propenso. |
| Tasa de Conversión (Decil 1) | 7.65% | 7.78% | +1.70% | Impacto directo en el retorno esperado de las campañas. |
| Recall | 65.15% | 65.66% | +0.78% | Mejor capacidad de captación de clientes reales. |
| Precision | 0.1239 | 0.1267 | +2.26% | Ligera mejora en la pureza de las predicciones. |
En conjunto, el modelo final no solo mejora de forma consistente las métricas clave, sino que lo hace de manera equilibrada y coherente con los objetivos de negocio. El aumento sustancial del PR AUC —métrica de referencia en escenarios altamente desbalanceados— confirma una mejora real en la calidad predictiva, mientras que el refuerzo del recall y la estabilidad de la precision consolidan un sistema más eficiente, robusto y alineado con la toma de decisiones operativas.
Análisis de umbrales de probabilidad y su impacto en el desempeño del modelo
En este apartado se analiza de forma sistemática el efecto del umbral de probabilidad sobre el desempeño del modelo, evaluando cómo la variación de dicho umbral impacta en las principales métricas de clasificación. Para ello, se presentan tanto visualizaciones como tablas comparativas que permiten identificar puntos operativos relevantes, incluyendo el umbral que maximiza el F1-score, el equilibrio entre precision y recall, los niveles de captación deseados y el umbral por defecto del sistema. Este análisis proporciona una base objetiva para seleccionar el umbral más adecuado según los objetivos y restricciones del negocio.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import precision_recall_curve, auc
# Suponiendo que y_test y y_pred_proba están definidos fuera de este bloque.
# --- 1. Preparación de Datos ---
precisions, recalls, thresholds_calc = precision_recall_curve(y_test, y_pred_proba)
thresholds = np.append(thresholds_calc, 1.0)
f1_scores = 2 * (precisions * recalls) / (precisions + recalls)
f1_scores[np.isnan(f1_scores)] = 0
best_f1_idx = np.argmax(f1_scores)
best_threshold = thresholds[best_f1_idx]
cross_idx = np.argmin(np.abs(precisions - recalls))
cross_threshold = thresholds[cross_idx]
DEFAULT_THRESHOLD = 0.5
# Nuevo: Umbral donde Recall = 0.8
TARGET_RECALL = 0.80
recall_idx = np.argmin(np.abs(recalls - TARGET_RECALL))
high_recall_threshold = thresholds[recall_idx]
# Función auxiliar
def get_metric_value_at_threshold(target_threshold, thresholds_calc, metrics):
all_thresholds = np.insert(thresholds_calc, 0, 0.0)
if target_threshold >= 1.0:
return metrics[-1]
if target_threshold <= all_thresholds[0]:
return metrics[0]
idx = np.searchsorted(all_thresholds, target_threshold, side='left')
return metrics[idx - 1]
# --- 2. Cálculo de P/R/F1 para todos los umbrales marcados ---
thresholds_to_mark = [
DEFAULT_THRESHOLD,
best_threshold,
cross_threshold,
high_recall_threshold
]
marker_data = {}
for tau in thresholds_to_mark:
marker_data[tau] = {
'P': get_metric_value_at_threshold(tau, thresholds_calc, precisions),
'R': get_metric_value_at_threshold(tau, thresholds_calc, recalls),
'F1': get_metric_value_at_threshold(tau, thresholds_calc, f1_scores[:-1])
}
# --- 3. Gráfica ---
plt.figure(figsize=(11, 7))
plt.title("Evolución de Métricas en función del Umbral de Probabilidad", fontsize=14)
plt.xlabel("Umbral de Probabilidad (τ)", fontsize=12)
plt.ylabel("Métrica de Rendimiento", fontsize=12)
plt.plot(thresholds, precisions, label='Precisión', color='green', linewidth=2)
plt.plot(thresholds, recalls, label='Recall (Tasa de Captura)', color='blue', linewidth=2)
plt.plot(thresholds, f1_scores, label='F1-Score', color='orange', linestyle='--', linewidth=2)
# --- Estilo común para líneas verticales (excepto la de por defecto) ---
VLINE_STYLE = {'linestyle': '--', 'linewidth': 1.2}
# --- Umbral por defecto (se mantiene) ---
plt.axvline(DEFAULT_THRESHOLD, color='red', linestyle='--', linewidth=1.5)
data = marker_data[DEFAULT_THRESHOLD]
plt.text(DEFAULT_THRESHOLD + 0.01, 0.95, 'Umbral Por Defecto (0.5)',
rotation=90, color='red', verticalalignment='top')
plt.plot(DEFAULT_THRESHOLD, data['P'], 'o', color='red', markersize=5)
plt.plot(DEFAULT_THRESHOLD, data['R'], 'o', color='red', markersize=5)
plt.plot(DEFAULT_THRESHOLD, data['F1'], 'o', color='red', markersize=5)
plt.text(DEFAULT_THRESHOLD - 0.01, data['P'] + 0.02, f'{data["P"]:.2f}',
color='red', fontsize=9, ha='right')
plt.text(DEFAULT_THRESHOLD - 0.01, data['R'] - 0.03, f'{data["R"]:.2f}',
color='red', fontsize=9, ha='right')
# --- Umbral de F1 Máximo ---
plt.axvline(best_threshold, color='black', **VLINE_STYLE)
data = marker_data[best_threshold]
plt.text(best_threshold + 0.01, 0.95, f'Umbral F1 Máx: {best_threshold:.6f}',
rotation=90, color='black', verticalalignment='bottom')
plt.plot(best_threshold, data['P'], 'o', color='black', markersize=5)
plt.plot(best_threshold, data['R'], 'o', color='black', markersize=5)
plt.plot(best_threshold, data['F1'], 'o', color='black', markersize=5)
plt.text(best_threshold + 0.02, data['F1'] + 0.01, f'{data["F1"]:.2f}',
color='black', fontsize=9, ha='left')
# --- Umbral de Cruce P=R ---
plt.axvline(cross_threshold, color='darkgreen', **VLINE_STYLE)
data = marker_data[cross_threshold]
plt.text(cross_threshold + 0.01, 0.95, f'Umbral Cruce (P≈R): {cross_threshold:.3f}',
rotation=90, color='darkgreen', verticalalignment='top')
plt.plot(cross_threshold, data['P'], 'o', color='darkgreen', markersize=5)
plt.plot(cross_threshold, data['R'], 'o', color='darkgreen', markersize=5)
plt.plot(cross_threshold, data['F1'], 'o', color='darkgreen', markersize=5)
plt.text(cross_threshold - 0.01, data['P'] + 0.01, f'{data["P"]:.2f}',
color='darkgreen', fontsize=9, ha='right')
# --- Nuevo: Umbral de Recall = 0.8 ---
plt.axvline(high_recall_threshold, color='blue', **VLINE_STYLE)
data = marker_data[high_recall_threshold]
plt.text(high_recall_threshold + 0.01, 0.95,
f'Umbral Alta Captación (R=0.8): {high_recall_threshold:.3f}',
rotation=90, color='blue', verticalalignment='top')
plt.plot(high_recall_threshold, data['P'], 'o', color='blue', markersize=5)
plt.plot(high_recall_threshold, data['R'], 'o', color='blue', markersize=5)
plt.plot(high_recall_threshold, data['F1'], 'o', color='blue', markersize=5)
plt.text(high_recall_threshold - 0.01,
data['P'] + 0.015, f'{data["P"]:.2f}',
color='blue', fontsize=9, ha='right')
plt.text(high_recall_threshold - 0.01,
data['R'] - 0.03, f'{data["R"]:.2f}',
color='blue', fontsize=9, ha='right')
# --- Estética del gráfico (Modificaciones clave para quitar el borde) ---
ax = plt.gca()
# Desactivar los cuatro bordes (spines) del gráfico
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.spines['left'].set_visible(False)
plt.legend(
loc='upper center',
bbox_to_anchor=(0.5, 1.15),
ncol=3,
fontsize=10
)
plt.grid(True, alpha=0.5)
# Modificación: mostrar desde x = 0.3
plt.xlim([0.3, 1.0])
plt.ylim([0.0, 1.05])
plt.show()
import numpy as np
import pandas as pd
from sklearn.metrics import precision_recall_curve, confusion_matrix, recall_score, precision_score, f1_score
# --- DATOS ASUMIDOS DEL ENTORNO ---
TOTAL_CLIENTES = y_test.sum()
TOTAL_MUESTRAS = len(y_test)
# ------------------------------------
def calculate_metrics_at_threshold(y_true, y_prob, threshold, total_clientes, total_muestras):
"""Calcula Precision, Recall, F1 y los TP/FP para un umbral dado."""
y_pred = (y_prob >= threshold).astype(int)
prec = precision_score(y_true, y_pred, zero_division=0)
rec = recall_score(y_true, y_pred, zero_division=0)
f1 = f1_score(y_true, y_pred, zero_division=0)
tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
total_no_clientes = total_muestras - total_clientes
fp_rate = (fp / total_no_clientes) * 100 if total_no_clientes > 0 else 0
return {
'Umbral (τ)': f'{threshold:.4f}',
'Precision': f'{prec:.4f}',
'Recall': f'{rec:.4f}',
'F1-Score': f'{f1:.4f}',
'Tasa Captura %': f'{rec * 100:.2f}%',
'% Falsos Positivos': f'{fp_rate:.2f}%'
}
def find_key_thresholds(y_true, y_prob):
"""Encuentra los umbrales de F1 Máximo, Cruce P≈R y Recall = 80%."""
precisions, recalls, thresholds = precision_recall_curve(y_true, y_prob)
# 1. Umbral F1 Máximo
f1_scores = 2 * (precisions * recalls) / (precisions + recalls)
f1_scores[np.isnan(f1_scores)] = 0
best_f1_idx = np.nanargmax(f1_scores)
thr_f1max = thresholds[best_f1_idx]
# 2. Umbral donde Precision ≈ Recall
cross_idx = np.argmin(np.abs(precisions - recalls))
thr_cross = thresholds[cross_idx]
# 3. Umbral para recall ≥ 0.80
target_recall = 0.80
idx_recall80 = np.argmin(np.abs(recalls - target_recall))
# OJO: precision_recall_curve retorna thresholds con longitud n-1 respecto a recalls
thr_recall80 = thresholds[max(0, idx_recall80 - 1)]
# 4. Por defecto
DEFAULT_THRESHOLD = 0.5
return {
'F1_MAX': thr_f1max,
'CROSS': thr_cross,
'RECALL_80': thr_recall80,
'DEFAULT': DEFAULT_THRESHOLD
}
# --- EJECUCIÓN ---
# 1. Obtener umbrales
thresholds_dict = find_key_thresholds(y_test, y_pred_proba)
# 2. Definir los escenarios
thresholds_to_evaluate = {
'Alta captacion (Recall=80%)': thresholds_dict['RECALL_80'],
'Por Defecto': thresholds_dict['DEFAULT'],
'F1 Máximo': thresholds_dict['F1_MAX'],
'Cruce P≈R': thresholds_dict['CROSS']
}
# 3. Calcular métricas para cada punto
results = []
for name, threshold in thresholds_to_evaluate.items():
metrics = calculate_metrics_at_threshold(
y_test, y_pred_proba, threshold, TOTAL_CLIENTES, TOTAL_MUESTRAS
)
metrics['Escenario'] = name
metrics['Umbral_num'] = float(metrics['Umbral (τ)']) # soporte para ordenar
results.append(metrics)
# 4. Crear DataFrame
df_analysis = pd.DataFrame(results)
# 5. Ordenar por umbral de menor a mayor
df_analysis = df_analysis.sort_values(by='Umbral_num').drop(columns='Umbral_num')
# 6. Ordenar columnas en el orden solicitado
column_order = [
'Escenario',
'Umbral (τ)',
'Precision',
'Recall',
'F1-Score',
'Tasa Captura %',
'% Falsos Positivos'
]
df_analysis = df_analysis[column_order]
# 7. Mostrar
display(df_analysis)
| Escenario | Umbral (τ) | Precision | Recall | F1-Score | Tasa Captura % | % Falsos Positivos | |
|---|---|---|---|---|---|---|---|
| 0 | Alta captacion (Recall=80%) | 0.3397 | 0.0621 | 0.8030 | 0.1153 | 80.30% | 12.42% |
| 1 | Por Defecto | 0.5000 | 0.1267 | 0.6566 | 0.2124 | 65.66% | 4.64% |
| 3 | Cruce P≈R | 0.9447 | 0.4545 | 0.4545 | 0.4545 | 45.45% | 0.56% |
| 2 | F1 Máximo | 0.9997 | 0.8295 | 0.3687 | 0.5105 | 36.87% | 0.08% |
📌Conclusiones
El análisis de los distintos umbrales de probabilidad confirma que el modelo presenta una notable versatilidad operativa, permitiendo su adaptación a múltiples objetivos de negocio sin necesidad de reentrenamiento. La gráfica evidencia cómo la modulación del umbral modifica de manera controlada la relación entre precisión, recall y tasa de falsos positivos, ofreciendo un abanico de configuraciones que pueden alinearse con estrategias diferenciadas.
En escenarios donde la prioridad es maximizar la captación —por ejemplo, campañas de alcance amplio o detección temprana— el umbral bajo asociado a Recall=80% permite alcanzar una tasa de captura del 80.30%, aun cuando implique una reducción en precisión y un incremento en falsos positivos. En contraste, el umbral por defecto ofrece un equilibrio razonable entre sensibilidad (65.66%) y control de falsas alarmas (4.64%), lo que lo convierte en una opción estable para operaciones generales.
Para entornos donde la precisión es crítica, el umbral asociado al F1 máximo o al punto de cruce entre precisión y recall permite alcanzar métricas más equilibradas. En particular, el umbral de cruce (P≈R), cercano a 0.945, proporciona una configuración con precisión y recall simétricos (45.45%) y una tasa muy reducida de falsos positivos (0.56%). Finalmente, el umbral extremo correspondiente al F1 máximo ofrece la mayor precisión del conjunto (82.95%) y minimiza las falsas alarmas (0.08%), resultando idóneo para casos en los que el costo de una clasificación errónea es elevado.
En conjunto, estos resultados muestran que el modelo no solo alcanza un desempeño robusto, sino que además puede configurarse dinámicamente en función de las prioridades operativas. Esta flexibilidad amplía su aplicabilidad a distintos escenarios empresariales, desde estrategias de alto alcance hasta políticas restrictivas orientadas a minimizar el riesgo, maximizando así su valor en la toma de decisiones.
Interpretabilidad e Insights
En esta sección se analiza cómo el modelo de predicción de compradores construye sus decisiones, combinando un enfoque global, local y estructural. En primer lugar, se estudia la contribución de cada variable mediante un enfoque multicriterio que integra las importancias nativas de LightGBM y CatBoost, la contribución marginal global estimada mediante SHAP y la sensibilidad del rendimiento ante perturbaciones calculada a través de Permutation Importance. Estas métricas comparten una interpretación común: valores más elevados indican una mayor relevancia de la variable en el proceso de decisión, lo que permite construir un ranking consolidado a partir de un promedio normalizado.
La métrica estabilidad_sigma, derivada de la variabilidad observada mediante procedimientos de bootstrap, no sigue esta misma lógica interpretativa. En este caso, valores elevados indican una mayor inestabilidad en la estimación de la importancia, y no una mayor relevancia predictiva. Por este motivo, dicha métrica no se incorpora al promedio normalizado y se utiliza como una dimensión adicional de análisis, permitiendo distinguir entre variables con una contribución sólida y consistente y aquellas cuya influencia depende de manera significativa del subconjunto de datos considerado.
Sobre esta base global se incorpora un segundo nivel de análisis centrado en la interpretabilidad local, mediante el uso de SHAP, que permite examinar cómo el modelo razona a nivel individual y extraer perfiles explicativos de comportamiento, siguiendo el marco teórico propuesto por Lundberg y Lee (2017). Finalmente, y una vez caracterizado el espacio predictivo aprendido por el modelo, el análisis se complementa con técnicas de clustering no supervisado orientadas a explorar la estructura latente del conjunto de usuarios. Este enfoque no persigue mejorar el rendimiento del modelo, sino aportar una lectura agregada de los patrones de comportamiento identificados, permitiendo detectar perfiles minoritarios o atípicos que, aun no alineándose con los patrones mayoritarios, presentan tasas de conversión significativamente superiores a la media. De este modo, la sección de insights no solo identifica qué variables son importantes, sino también cómo, para quién y con qué limitaciones el modelo aprende los patrones de compra.
Insight 1: Qué variables explican la probabilidad de compra (análisis global de importancia)
#!pip install shap
# Archivo donde se guardarán las importancias
IMPORTANCE_FILE = "results/importances/importances_stacking.pkl"
# ========================================================
# Comprobar si ya existe el archivo guardado
# ========================================================
if os.path.exists(IMPORTANCE_FILE):
print(f"Cargando importancias desde {IMPORTANCE_FILE}...")
df_importance = joblib.load(IMPORTANCE_FILE)
display(df_importance)
else:
print("Archivo no encontrado. Calculando importancias...")
# ========================================================
# 1. Extraer modelos base y meta-modelo
# ========================================================
lgbm = model.named_estimators_["lgbm"]
cb = model.named_estimators_["catboost"]
meta_model = model.final_estimator_
# ========================================================
# 2. Importancias nativas
# ========================================================
lgbm_imp = pd.Series(lgbm.feature_importances_, index=X_test.columns)
cb_imp = pd.Series(cb.feature_importances_, index=X_test.columns)
lgbm_imp /= lgbm_imp.max()
cb_imp /= cb_imp.max()
# ========================================================
# 3. SHAP Global Importance (por modelo base)
# ========================================================
print("Calculando SHAP para LGBM...")
explainer_lgbm = shap.TreeExplainer(lgbm)
shap_lgbm = explainer_lgbm.shap_values(X_test)
sv_lgbm = shap_lgbm[1] if isinstance(shap_lgbm, list) else shap_lgbm
shap_lgbm_global = pd.Series(np.abs(sv_lgbm).mean(axis=0), index=X_test.columns)
shap_lgbm_global /= shap_lgbm_global.max()
print("Calculando SHAP para CatBoost...")
explainer_cb = shap.TreeExplainer(cb)
shap_cb = explainer_cb.shap_values(X_test)
sv_cb = shap_cb[1] if isinstance(shap_cb, list) else shap_cb
shap_cb_global = pd.Series(np.abs(sv_cb).mean(axis=0), index=X_test.columns)
shap_cb_global /= shap_cb_global.max()
# ========================================================
# 4. Meta-ponderación usando coeficientes del meta-modelo
# ========================================================
coefs = pd.Series(np.abs(meta_model.coef_[0]), index=list(model.named_estimators_.keys()))
meta_weights = coefs / coefs.sum()
shap_global = meta_weights["lgbm"] * shap_lgbm_global + meta_weights["catboost"] * shap_cb_global
shap_global /= shap_global.max()
meta_combined = meta_weights["lgbm"] * lgbm_imp + meta_weights["catboost"] * cb_imp
# ========================================================
# 5. Permutation Importance
# ========================================================
def pr_auc_scorer(y_true, y_scores):
from sklearn.metrics import precision_recall_curve, auc
p, r, _ = precision_recall_curve(y_true, y_scores)
return auc(r, p)
print("Calculando Permutation Importance...")
perm = permutation_importance(
estimator=model,
X=X_test,
y=y_test,
n_repeats=20,
scoring=lambda est, X, y: pr_auc_scorer(y, est.predict_proba(X)[:, 1]),
random_state=42
)
perm_imp = pd.Series(perm.importances_mean, index=X_test.columns)
perm_imp /= perm_imp.max()
# ========================================================
# 6. Estabilidad mediante bootstrap
# ========================================================
print("Calculando estabilidad mediante bootstrap...")
n_boot = 30
bootstrap_importances = []
for _ in tqdm(range(n_boot), desc="Bootstrap"):
sample_idx = np.random.choice(len(X_test), size=len(X_test), replace=True)
Xb, yb = X_test.iloc[sample_idx], y_test.iloc[sample_idx]
pb = permutation_importance(
estimator=model,
X=Xb,
y=yb,
n_repeats=10,
scoring=lambda est, X, y: pr_auc_scorer(y, est.predict_proba(X)[:, 1]),
random_state=42
)
bootstrap_importances.append(pb.importances_mean)
bootstrap_matrix = np.vstack(bootstrap_importances)
stability_sigma = pd.Series(bootstrap_matrix.std(axis=0), index=X_test.columns)
stability_sigma /= stability_sigma.max()
# ========================================================
# 7. Construir tabla final
# ========================================================
df_importance = pd.DataFrame({
"LGBM_Imp": lgbm_imp,
"CB_Imp": cb_imp,
"SHAP_Global": shap_global,
"Permutation": perm_imp,
"Meta_Ponderada": meta_combined,
"Estabilidad_sigma": stability_sigma
})
df_importance = df_importance.sort_values("Meta_Ponderada", ascending=False)
# ========================================================
# 8. Guardar resultados para la próxima ejecución
# ========================================================
os.makedirs('results/importances', exist_ok=True)
joblib.dump(df_importance, IMPORTANCE_FILE)
print(f"Importancias calculadas y guardadas en {IMPORTANCE_FILE}.")
Cargando importancias desde results/importances/importances_stacking.pkl...
| LGBM_Imp | CB_Imp | SHAP_Global | Permutation | Meta_Ponderada | Estabilidad_sigma | |
|---|---|---|---|---|---|---|
| total_clicks | 1.000000 | 0.305091 | 0.303667 | 0.861299 | 0.714616 | 0.995085 |
| engagement_por_email | 0.453042 | 1.000000 | 1.000000 | 0.288708 | 0.677666 | 0.568089 |
| total_fichas_consultadas | 0.828439 | 0.266842 | 0.408262 | 1.000000 | 0.597803 | 1.000000 |
| canal | 0.438757 | 0.636954 | 0.201008 | 0.183177 | 0.520152 | 0.583679 |
| usuarios_que_consultan_misma_primera_ficha | 0.496429 | 0.233741 | 0.499625 | 0.542536 | 0.388549 | 0.873933 |
| clicks_por_sesion | 0.405952 | 0.305598 | 0.156818 | 0.189655 | 0.364739 | 0.501776 |
| recencia_fichas | 0.427646 | 0.184102 | 0.467654 | 0.283895 | 0.327628 | 0.574166 |
| total_sesiones | 0.436640 | 0.117508 | 0.068511 | 0.122639 | 0.305580 | 0.369875 |
| tipo_usuario | 0.305159 | 0.226948 | 0.212590 | 0.090443 | 0.273039 | 0.411157 |
| sesiones_por_dia | 0.258598 | 0.199281 | 0.092962 | 0.036277 | 0.234238 | 0.180731 |
| mes_registro | 0.175397 | 0.270551 | 0.109770 | 0.041422 | 0.214475 | 0.150747 |
| total_clicks_por_dia | 0.281349 | 0.108402 | 0.083993 | 0.021817 | 0.210324 | 0.194390 |
| recencia_ficha_unica | 0.221032 | 0.124688 | 0.091000 | 0.060621 | 0.181465 | 0.268354 |
| clicks_si_fichas | 0.097751 | 0.101392 | 0.118809 | 0.072819 | 0.099246 | 0.253899 |
| antiguedad_comportamiento_fichas | 0.126190 | 0.043731 | 0.010123 | 0.022824 | 0.092326 | 0.118068 |
# ========================================================
# 1. Normalizar métricas orientadas a importancia
# ========================================================
importance_metrics = ["LGBM_Imp", "CB_Imp", "SHAP_Global", "Permutation", "Meta_Ponderada"]
df_norm = df_importance.copy()
df_norm[importance_metrics] = df_norm[importance_metrics].div(df_norm[importance_metrics].max())
# ========================================================
# 2. Heatmap de importancias
# ========================================================
plt.figure(figsize=(12,6))
sns.heatmap(df_norm[importance_metrics], annot=True, cmap="YlGnBu")
plt.title("Heatmap de importancias por métricas")
plt.ylabel("Features")
plt.show()
# ========================================================
# 3. Calcular importancia promedio (sin estabilidad_sigma)
# ========================================================
df_norm["Importancia_prom"] = df_norm[importance_metrics].mean(axis=1)
# ========================================================
# 4. Clasificar features según importancia
# ========================================================
def classify_importance(val):
if val >= 0.60:
return "Alta"
elif val >= 0.20:
return "Media"
else:
return "Baja"
df_norm["Categoria_importancia"] = df_norm["Importancia_prom"].apply(classify_importance)
# ========================================================
# 5. Normalizar estabilidad_sigma y clasificar estabilidad
# ========================================================
df_norm["Estabilidad_sigma_norm"] = df_norm["Estabilidad_sigma"] / df_norm["Estabilidad_sigma"].max()
def classify_stability(val):
if val <= 0.33:
return "Alta estabilidad"
elif val <= 0.66:
return "Estabilidad media"
else:
return "Baja estabilidad"
df_norm["Estabilidad_categoria"] = df_norm["Estabilidad_sigma_norm"].apply(classify_stability)
# ========================================================
# 6. Ordenar ranking por importancia promedio
# ========================================================
df_norm_sorted = df_norm.sort_values("Importancia_prom", ascending=False)
# ========================================================
# 7. Mostrar tabla final con categorías en primera columna
# ========================================================
display(df_norm_sorted[[
"Categoria_importancia",
"Importancia_prom",
"Estabilidad_categoria",
"Estabilidad_sigma_norm"
]])
# ========================================================
# 8. Gráfico final: ranking coloreado por categoría (viridis)
# ========================================================
plt.figure(figsize=(10,5))
sns.barplot(
x=df_norm_sorted.index,
y=df_norm_sorted["Importancia_prom"],
hue=df_norm_sorted["Categoria_importancia"],
palette="viridis"
)
plt.xticks(rotation=45, ha="right")
plt.ylabel("Importancia promedio normalizada")
plt.title("Ranking consolidado de features por categoría")
plt.legend(title="Categoría")
plt.show()
| Categoria_importancia | Importancia_prom | Estabilidad_categoria | Estabilidad_sigma_norm | |
|---|---|---|---|---|
| engagement_por_email | Alta | 0.738009 | Estabilidad media | 0.568089 |
| total_clicks | Alta | 0.694011 | Baja estabilidad | 0.995085 |
| total_fichas_consultadas | Alta | 0.668016 | Baja estabilidad | 1.000000 |
| usuarios_que_consultan_misma_primera_ficha | Media | 0.463210 | Baja estabilidad | 0.873933 |
| canal | Media | 0.437555 | Estabilidad media | 0.583679 |
| recencia_fichas | Media | 0.364353 | Estabilidad media | 0.574166 |
| clicks_por_sesion | Media | 0.313684 | Estabilidad media | 0.501776 |
| tipo_usuario | Media | 0.243444 | Estabilidad media | 0.411157 |
| total_sesiones | Media | 0.234582 | Estabilidad media | 0.369875 |
| sesiones_por_dia | Baja | 0.182980 | Alta estabilidad | 0.180731 |
| mes_registro | Baja | 0.179453 | Alta estabilidad | 0.150747 |
| total_clicks_por_dia | Baja | 0.157976 | Alta estabilidad | 0.194390 |
| recencia_ficha_unica | Baja | 0.150255 | Alta estabilidad | 0.268354 |
| clicks_si_fichas | Baja | 0.105930 | Alta estabilidad | 0.253899 |
| antiguedad_comportamiento_fichas | Baja | 0.066413 | Alta estabilidad | 0.118068 |
# =========================================================
# 1. Preparar datos desde df_norm_sorted
# (usa tus columnas reales del dataframe)
# =========================================================
features = df_norm_sorted.index.tolist()
importance = df_norm_sorted["Importancia_prom"].values
stability = df_norm_sorted["Estabilidad_sigma_norm"].values
# Normalizar importancia para asignar colores de viridis
colors = plt.cm.viridis((importance - importance.min()) / (importance.max() - importance.min()))
# =========================================================
# 2. Ajustar línea de regresión
# =========================================================
coef = np.polyfit(importance, stability, 1)
reg_fn = np.poly1d(coef)
# =========================================================
# 3. Crear scatter plot
# =========================================================
plt.figure(figsize=(10,6))
# Puntos coloreados con viridis
plt.scatter(importance, stability, c=colors)
# Línea de regresión
x_vals = np.linspace(importance.min(), importance.max(), 100)
plt.plot(x_vals, reg_fn(x_vals), color="goldenrod", linewidth=2)
plt.xlabel("Importancia promedio normalizada")
plt.ylabel("Estabilidad sigma normalizada")
plt.title("Scatter: Importancia vs Estabilidad con línea de regresión")
# Etiquetas para cada punto
for i, feat in enumerate(features):
plt.annotate(feat, (importance[i], stability[i]), fontsize=8, xytext=(3,3), textcoords="offset points")
plt.grid(True)
plt.show()
# --- Selección de columnas de importancia ---
importance_cols = ["LGBM_Imp", "CB_Imp", "SHAP_Global", "Permutation", "Meta_Ponderada", "Estabilidad_sigma"]
# --- 1. Correlación entre métricas de importancia ---
corr_matrix = df_importance[importance_cols].corr(method="spearman")
plt.figure(figsize=(8,6))
sns.heatmap(corr_matrix, annot=True, cmap="coolwarm")
plt.title("Correlación de ranking entre métricas de importancia")
plt.show()
# --- Texto resumen de correlación entre métricas ---
#print("Correlación entre métricas de importancia (Spearman):")
#for col1 in importance_cols:
# for col2 in importance_cols:
# if col1 != col2:
# corr_val = corr_matrix.loc[col1, col2]
# print(f"- {col1} vs {col2}: {corr_val:.2f}")
# --- 2. Correlación entre features según su importancia ---
feature_corr = df_importance[importance_cols].T.corr(method="spearman")
plt.figure(figsize=(10,8))
sns.heatmap(feature_corr, cmap="coolwarm", center=0)
plt.title("Correlación entre features según sus importancias")
plt.show()
# --- Texto resumen de correlación entre features ---
#print("\nCorrelación de ranking entre features (Spearman):")
#features = df_importance.index.tolist()
#for i, f1 in enumerate(features):
# for j, f2 in enumerate(features):
# if j > i:
# # Evitar duplicados
# corr_val = feature_corr.loc[f1, f2]
# print(f"- {f1} vs {f2}: {corr_val:.2f}")
📌Conclusiones
1. Análisis por métrica
LGBM_Imp: total_clicks y total_fichas_consultadas aparecen como los predictores más influyentes. LightGBM prioriza señales fuertes de interacción directa en el catálogo, lo que refuerza que la actividad del usuario es un determinante clave en la probabilidad de compra.
CB_Imp: destacan engagement_por_email y canal. CatBoost captura patrones en variables categóricas y evidencia que la procedencia del usuario y su respuesta a comunicaciones externas aportan información que complementa a las variables de comportamiento.
SHAP_Global: engagement_por_email presenta la mayor contribución marginal global, seguida por usuarios_que_consultan_misma_primera_ficha y total_clicks. SHAP muestra que el engagement por email ejerce un efecto sistemático sobre las predicciones, aunque su impacto no siempre coincida con el de otras métricas, lo que indica que influye en la forma del modelo más que en la importancia interna asignada por cada algoritmo.
Permutation: total_fichas_consultadas y total_clicks generan la mayor caída de rendimiento al ser desordenadas, confirmando la dependencia real del modelo hacia comportamientos intensivos de navegación y consulta. Otras variables aportan efecto, pero su ausencia perjudica menos la capacidad predictiva.
Estabilidad_sigma: total_fichas_consultadas y total_clicks muestran mayor variabilidad entre iteraciones bootstrap. Un sigma alto indica que su relevancia es sensible a la muestra utilizada. Variables con sigma bajo presentan un comportamiento más uniforme, lo que permite distinguir entre fuerza predictiva y robustez.
2. Heatmap y ranking consolidado
El heatmap confirma un núcleo crítico compuesto por total_clicks, total_fichas_consultadas y engagement_por_email. Aunque engagement_por_email aparece menos destacada en Permutation y estabilidad, su presencia dominante en SHAP y CatBoost confirma que aporta una señal informativa estructural. Las variables moderadas (usuarios_que_consultan_misma_primera_ficha, canal, recencia_fichas, clicks_por_sesion) complementan el modelo pero con menor peso. Las variables de baja importancia muestran impacto limitado.
3. Correlaciones entre métricas
Las métricas de importancia presentan correlaciones elevadas, lo que respalda la consistencia del análisis. La relación entre Permutation y estabilidad indica que las variables con mayor impacto en el rendimiento también son las más sensibles al muestreo. La meta-ponderada se alinea principalmente con LGBM_Imp, lo que evidencia que el meta-modelo se apoya especialmente en LightGBM. CatBoost esta menos alineada en ponderacion meta, estabilidad y permutación, lo que puede reflejar que detecta patrones distintos. SHAP introduce un ángulo complementario basado en contribuciones marginales mas equilibrado entre Catboost y LightGBM.
4. Correlación entre variables
Se observan tres grupos funcionales: interacción intensiva (total_clicks, total_sesiones, clicks_por_sesion), exploración de catálogo (total_fichas_consultadas, recencia_fichas, usuarios_que_consultan_misma_primera_ficha) y engagement/canal (engagement_por_email, canal). Las correlaciones negativas entre engagement_por_email y variables de comportamiento intensivo sugieren perfiles distintos: usuarios navegadores frente a usuarios sensibles a comunicaciones.
5. Aportación del scatter de Importancia vs Estabilidad
El scatter añade una dimensión que permite diferenciar entre variables estratégicas y tácticas. engagement_por_email se sitúa en la zona de alta importancia y baja inestabilidad, por lo que es una variable estratégica: su señal es relevante, consistente y aplicable a decisiones globales. Por contraste, usuarios_que_consultan_misma_primera_ficha combina alta importancia con alta inestabilidad y actúa como variable táctica: aporta mucho valor en ciertos segmentos o patrones de navegación, pero no de forma homogénea en toda la población. Su potencial se maximiza cuando se utiliza en análisis segmentados o combinada con otras variables de comportamiento.
6. Conclusiones accionables
El modelo se apoya en variables que describen interacción profunda con el catálogo, por lo que es prioritario asegurar la calidad del tracking de total_clicks y total_fichas_consultadas. engagement_por_email debe considerarse una señal estratégica tanto para predicción como para decisiones de marketing. usuarios_que_consultan_misma_primera_ficha es una señal táctica que conviene utilizar de forma segmentada. Las variables moderadas podrían reforzarse mediante nuevas transformaciones. Las variables de baja importancia son candidatas a exclusión si se desea simplificar el modelo sin afectar su rendimiento. El conjunto de métricas y visualizaciones permite comprender no solo cuáles son las variables clave, sino también la estabilidad y naturaleza de su efecto.
Insight 2 - Perfiles explicativos del razonamiento del modelo (SHAP local y segmentación de usuarios)
Análisis local mediante SHAP
Tras el análisis global de importancia de variables, se profundiza en la interpretabilidad del modelo a nivel individual mediante explicaciones SHAP locales. Mientras que SHAP global permite identificar qué variables son relevantes en promedio, SHAP local explica cómo cada variable contribuye a la predicción de un usuario concreto, mostrando qué señales empujan la predicción hacia la compra o hacia la no compra.
Este análisis local resulta especialmente útil para detectar heterogeneidad en el comportamiento de los usuarios y comprender si el modelo razona de forma homogénea o distingue patrones diferenciados. Para ello, se analizan explicaciones SHAP locales sobre una muestra ampliada de usuarios del conjunto de test, a partir de la cual se identifican patrones recurrentes. Posteriormente, se seleccionan de forma automática tres usuarios representativos que ilustran perfiles de comportamiento característicos del modelo.
import shap
N_LOCAL_USERS = 100 # para inferencia
N_DISPLAY = 3 # para visualización
RANDOM_STATE = 42
# =====================================================
# 1. Seleccionar NUSERS (100) usuarios aleatorios del test
# =====================================================
np.random.seed(RANDOM_STATE)
local_indices = np.random.choice(len(X_test), size=N_LOCAL_USERS, replace=False)
# =====================================================
# 2. SHAP Local para LightGBM y CatBoost
# =====================================================
explainer_lgbm = shap.TreeExplainer(model.named_estimators_["lgbm"])
explainer_cb = shap.TreeExplainer(model.named_estimators_["catboost"])
shap_lgbm = explainer_lgbm.shap_values(X_test)
shap_cb = explainer_cb.shap_values(X_test)
shap_lgbm_pos = shap_lgbm[1] if isinstance(shap_lgbm, list) else shap_lgbm
shap_cb_pos = shap_cb[1] if isinstance(shap_cb, list) else shap_cb
# =====================================================
# df base para analisis
# =====================================================
rows = []
for idx in local_indices:
for i, feat in enumerate(X_test.columns):
# LightGBM
val_lgbm = shap_lgbm_pos[idx, i]
rows.append({
"user_id_local": idx,
"model": "LightGBM",
"feature": feat,
"shap_value": val_lgbm,
"abs_shap": abs(val_lgbm),
"direction": "positive" if val_lgbm > 0 else "negative"
})
# CatBoost
val_cb = shap_cb_pos[idx, i]
rows.append({
"user_id_local": idx,
"model": "CatBoost",
"feature": feat,
"shap_value": val_cb,
"abs_shap": abs(val_cb),
"direction": "positive" if val_cb > 0 else "negative"
})
df_shap_local = pd.DataFrame(rows)
# =====================================================
# Seleccion de 3 usuarios representativos
# =====================================================
pivot = (
df_shap_local[df_shap_local["model"] == "LightGBM"]
.pivot_table(
index="user_id_local",
columns="feature",
values="shap_value",
aggfunc="sum"
)
)
pivot["score_alta_intencion"] = (
pivot["engagement_por_email"] +
pivot["recencia_fichas"]
)
pivot["score_explorador"] = (
pivot["usuarios_que_consultan_misma_primera_ficha"] +
pivot["total_fichas_consultadas"]
)
pivot["score_baja_intencion"] = -pivot["engagement_por_email"]
u_high = pivot["score_alta_intencion"].idxmax()
u_explorer = pivot["score_explorador"].idxmax()
u_low = pivot["score_baja_intencion"].idxmax()
selected_users = {
u_high: "Alta intención",
u_explorer: "Explorador / mixto",
u_low: "Baja intención"
}
# =====================================================
# Resumen agregado
# =====================================================
top5 = (
df_shap_local
.sort_values(["user_id_local", "model", "abs_shap"], ascending=[True, True, False])
.groupby(["user_id_local", "model"])
.head(5)
[["user_id_local", "model", "feature"]]
)
freq_top5 = (
top5
.drop_duplicates(["user_id_local", "model", "feature"])
.groupby("feature")
.size()
)
summary = (
df_shap_local
.groupby("feature")
.agg(
mean_abs_shap=("abs_shap", "mean"),
freq_positive=("direction", lambda x: (x == "positive").sum()),
freq_negative=("direction", lambda x: (x == "negative").sum())
)
.join(freq_top5.rename("freq_top5"))
.fillna({"freq_top5": 0})
.sort_values("mean_abs_shap", ascending=False)
)
summary["dominant_direction"] = np.where(
summary["freq_positive"] > summary["freq_negative"],
"tiende a empujar hacia compra",
"tiende a empujar hacia NO compra"
)
summary["pattern"] = summary["freq_top5"].apply(
lambda x: (
"recurrente en la mayoría de usuarios"
if x >= 0.6 * (N_LOCAL_USERS * 2) else
"relevante en algunos usuarios"
if x >= 0.2 * (N_LOCAL_USERS * 2) else
"marginal en análisis local"
)
)
summary
| mean_abs_shap | freq_positive | freq_negative | freq_top5 | dominant_direction | pattern | |
|---|---|---|---|---|---|---|
| feature | ||||||
| engagement_por_email | 0.798203 | 148 | 52 | 200.0 | tiende a empujar hacia compra | recurrente en la mayoría de usuarios |
| usuarios_que_consultan_misma_primera_ficha | 0.401830 | 158 | 42 | 153.0 | tiende a empujar hacia compra | recurrente en la mayoría de usuarios |
| recencia_fichas | 0.334340 | 52 | 148 | 111.0 | tiende a empujar hacia NO compra | relevante en algunos usuarios |
| total_fichas_consultadas | 0.313620 | 24 | 176 | 161.0 | tiende a empujar hacia NO compra | recurrente en la mayoría de usuarios |
| total_clicks | 0.228964 | 162 | 38 | 103.0 | tiende a empujar hacia compra | relevante en algunos usuarios |
| tipo_usuario | 0.158631 | 22 | 178 | 65.0 | tiende a empujar hacia NO compra | relevante en algunos usuarios |
| canal | 0.149498 | 38 | 162 | 52.0 | tiende a empujar hacia NO compra | relevante en algunos usuarios |
| clicks_por_sesion | 0.119431 | 149 | 51 | 27.0 | tiende a empujar hacia compra | marginal en análisis local |
| mes_registro | 0.109738 | 107 | 93 | 44.0 | tiende a empujar hacia compra | relevante en algunos usuarios |
| clicks_si_fichas | 0.102082 | 48 | 152 | 24.0 | tiende a empujar hacia NO compra | marginal en análisis local |
| sesiones_por_dia | 0.084876 | 166 | 34 | 23.0 | tiende a empujar hacia compra | marginal en análisis local |
| recencia_ficha_unica | 0.084778 | 96 | 104 | 18.0 | tiende a empujar hacia NO compra | marginal en análisis local |
| total_clicks_por_dia | 0.070326 | 177 | 23 | 16.0 | tiende a empujar hacia compra | marginal en análisis local |
| total_sesiones | 0.055923 | 177 | 23 | 3.0 | tiende a empujar hacia compra | marginal en análisis local |
| antiguedad_comportamiento_fichas | 0.005097 | 120 | 80 | 0.0 | tiende a empujar hacia compra | marginal en análisis local |
for idx, perfil in selected_users.items():
for mdl in ["LightGBM", "CatBoost"]:
df_u = (
df_shap_local[
(df_shap_local["user_id_local"] == idx) &
(df_shap_local["model"] == mdl)
]
.sort_values("abs_shap", ascending=False)
.head(10)
)
plt.figure(figsize=(6,4))
sns.barplot(
data=df_u,
x="shap_value",
y="feature",
palette="coolwarm"
)
plt.axvline(0, color="black", linewidth=1)
plt.title(f"SHAP local — {mdl} — Usuario {idx} ({perfil})")
plt.xlabel("Contribución SHAP")
plt.ylabel("Feature")
plt.tight_layout()
plt.show()
📌 Conclusión
El análisis de explicaciones SHAP locales revela que el modelo no razona de forma uniforme para todos los usuarios, sino que combina distintas señales en función del patrón de comportamiento individual. A partir de una muestra ampliada de usuarios, se identifican tres perfiles explicativos recurrentes que sintetizan cómo el modelo construye la probabilidad de compra a nivel individual.
Perfil 1 — Usuario de alta intención (estratégico)
- Contribuciones positivas dominantes en
engagement_por_emailyrecencia_fichas, reflejando interacción reciente y consistente. - Predicción empujada claramente hacia la compra, con pocas señales dominantes y alineadas.
Interpretación: El modelo identifica una intención clara y estructural. Estas señales coinciden con las variables estratégicas detectadas en el análisis global y muestran un comportamiento estable entre usuarios, lo que genera predicciones robustas y con margen.
Perfil 2 — Usuario explorador o mixto (táctico)
- Alta contribución de
usuarios_que_consultan_misma_primera_fichay otras variables asociadas a exploración del catálogo. - Coexistencia de señales positivas y negativas, sin un factor claramente dominante.
- Predicción sensible al contexto y al equilibrio entre señales.
Interpretación: El modelo detecta interés, pero no una intención de compra consolidada. Estas variables actúan como señales tácticas, relevantes solo bajo ciertos patrones de navegación, lo que explica su alta importancia local pero menor estabilidad a nivel global.
Perfil 3 — Usuario de baja intención (riesgo)
- Predominio de contribuciones negativas en variables clave.
- Baja actividad reciente y bajo
engagement_por_email. - Predicción empujada hacia la no compra.
Interpretación: El modelo identifica ausencia de señales claras de conversión. La predicción se construye principalmente a partir de la falta de actividad o de recencias elevadas, lo que sitúa a estos usuarios en un escenario de bajo potencial.
Aunque los perfiles de alta intención y explorador presentan actividad relevante en variables similares, el análisis SHAP local revela diferencias claras en la forma en que el modelo construye la predicción. En los usuarios de alta intención, la decisión se apoya en un conjunto reducido de señales fuertes y coherentes, mientras que en los usuarios exploradores la probabilidad de compra se construye a partir de múltiples señales de magnitud intermedia, incluyendo contribuciones negativas. Esta diferencia no implica el uso de variables distintas, sino roles distintos de las mismas variables en el proceso de decisión del modelo.
Análisis complementario de perfiles en el espacio predictivo
Con el objetivo de complementar el análisis de interpretabilidad local basado en SHAP, se exploran distintas técnicas de clustering no supervisado para identificar patrones de comportamiento recurrentes entre los usuarios. Este análisis se plantea como un ejercicio post-modelado, orientado a la interpretación y explotación del sistema, y no como una etapa destinada a optimizar el rendimiento predictivo ni a sustituir al modelo supervisado.
La segmentación se realiza sobre el espacio de variables que el propio modelo ha identificado como relevantes, lo que permite evaluar si los perfiles explicativos observados a nivel individual emergen también de forma agregada y estructural. En este sentido, el clustering actúa como una herramienta de validación conceptual y de apoyo a la toma de decisiones, más que como un mecanismo de generación de nuevas features.
En una primera aproximación se emplea K-Means con k=4, utilizando exclusivamente variables numéricas de comportamiento. Este método se selecciona por su simplicidad, interpretabilidad y capacidad para ofrecer una primera partición del espacio de usuarios basada en niveles generales de actividad e interacción. Aunque la separación visual entre grupos resulta limitada, los centroides permiten identificar perfiles con diferencias claras en intensidad de uso y patrones de navegación.
En una segunda fase se introduce K-Prototypes con k=4, incorporando variables categóricas relevantes como canal y tipo_usuario. Este algoritmo resulta adecuado en contextos mixtos y permite analizar si los atributos estáticos del usuario aportan estructura adicional más allá del comportamiento observado, contrastando así la contribución relativa de ambos tipos de información.
A continuación, se plantea una segmentación refinada con K-Means y k=3, ajustando tanto el número de clusters como el conjunto de variables a aquellas más alineadas con los perfiles explicativos detectados en el análisis SHAP local. Esta iteración prioriza la interpretabilidad y la coherencia conceptual frente a una segmentación excesivamente granular.
Finalmente, se explora DBSCAN como método complementario de detección de densidades y valores atípicos. Su inclusión se justifica por la observación de usuarios con comportamientos extremos o poco frecuentes que no quedan bien representados mediante centroides globales. DBSCAN permite aislar estos subconjuntos minoritarios, aportando una perspectiva adicional sobre perfiles que el modelo supervisado puede tratar como ruido, pero que potencialmente concentran señales de alto valor desde un punto de vista analítico o estratégico.
Segmentación inicial con K-means
# Variables recomendadas para segmentación conductual
features = [
"total_clicks",
"total_fichas_consultadas",
"recencia_fichas",
"engagement_por_email",
"clicks_por_sesion",
"total_sesiones",
]
X_seg = df_final_cb_lgbm[features].copy()
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_seg)
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import matplotlib.pyplot as plt
kMeans4FileInertias = "results/clustering/Kmeans_k4_inertias.pkl"
kMeans4FileScore = "results/clustering/Kmeans_k4_score.pkl"
Ks = range(2, 9)
inertias = []
scores = []
if os.path.exists(kMeans4FileInertias) and os.path.exists(kMeans4FileScore):
inertias = joblib.load(kMeans4FileInertias)
scores = joblib.load(kMeans4FileScore)
else:
for k in Ks:
print(f"fitting k={k}")
km = KMeans(n_clusters=k, random_state=42)
km.fit(X_scaled)
inertias.append(km.inertia_)
scores.append(silhouette_score(X_scaled, km.labels_))
os.makedirs("results/clustering", exist_ok=True)
joblib.dump(inertias, kMeans4FileInertias)
joblib.dump(scores, kMeans4FileScore)
plt.figure(figsize=(12,4))
plt.subplot(1,2,1)
plt.plot(Ks, inertias, marker='o')
plt.title("Método del Codo")
plt.xlabel("Número de clusters")
plt.ylabel("Inercia")
plt.subplot(1,2,2)
plt.plot(Ks, scores, marker='o')
plt.title("Silhouette Score")
plt.xlabel("Número de clusters")
plt.ylabel("Silhouette")
plt.show()
k=4 representa el mejor equilibrio entre cohesión, separación e interpretabilidad.”
k = 4
kmeans = KMeans(n_clusters=k, random_state=42)
labels = kmeans.fit_predict(X_scaled)
X_seg["cluster"] = labels
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
coords = pca.fit_transform(X_scaled)
plt.figure(figsize=(8,6))
plt.scatter(coords[:,0], coords[:,1], c=labels, cmap="viridis")
plt.title("Segmentación de usuarios — PCA + K-Means")
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.colorbar(label="Cluster")
plt.show()
cluster_summary = X_seg.groupby("cluster").mean().round(2)
cluster_summary
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | total_sesiones | |
|---|---|---|---|---|---|---|
| cluster | ||||||
| 0 | 2.43 | 0.06 | 2243.18 | 0.00 | 1.40 | 1.65 |
| 1 | 10.18 | 3.15 | 1097.01 | 1.55 | 2.27 | 4.44 |
| 2 | 273.32 | 149.76 | 965.92 | 1.82 | 2.10 | 132.84 |
| 3 | 3.01 | 0.01 | 2298.75 | 1.53 | 1.53 | 1.95 |
def classify_cluster(row):
if row["engagement_por_email"] > 1.6 and row["recencia_fichas"] < 1000:
return "Alta intención"
if row["total_fichas_consultadas"] > 3 and row["clicks_por_sesion"] > 2:
return "Explorador indeciso"
if row["engagement_por_email"] > 1.5 and row["recencia_fichas"] > 2000:
return "Usuario pasivo"
return "Baja intención o actividad limitada"
cluster_summary["perfil"] = cluster_summary.apply(classify_cluster, axis=1)
cluster_summary
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | total_sesiones | perfil | |
|---|---|---|---|---|---|---|---|
| cluster | |||||||
| 0 | 2.43 | 0.06 | 2243.18 | 0.00 | 1.40 | 1.65 | Baja intención o actividad limitada |
| 1 | 10.18 | 3.15 | 1097.01 | 1.55 | 2.27 | 4.44 | Explorador indeciso |
| 2 | 273.32 | 149.76 | 965.92 | 1.82 | 2.10 | 132.84 | Alta intención |
| 3 | 3.01 | 0.01 | 2298.75 | 1.53 | 1.53 | 1.95 | Usuario pasivo |
# ============================================================
# Tabla final de clusters con % de compra
# ============================================================
# Añadir la etiqueta de cluster al dataset original
df_clustered = df_final_cb_lgbm.copy()
df_clustered["cluster"] = labels
# Resumen numérico por cluster
cluster_summary = (
df_clustered
.groupby("cluster")[features]
.mean()
.round(2)
)
# Tasa de compra por cluster
conversion = (
df_clustered
.groupby("cluster")["es_cliente"]
.mean()
.rename("tasa_compra")
)
cluster_summary = cluster_summary.merge(
conversion,
left_index=True,
right_index=True
)
cluster_summary["tasa_compra_pct"] = (cluster_summary["tasa_compra"] * 100).round(2)
# Clasificación semántica del cluster
def classify_cluster(row):
if row["engagement_por_email"] > 1.6 and row["recencia_fichas"] < 1000:
return "Usuarios intensivos no compradores"
if row["total_fichas_consultadas"] > 3 and row["clicks_por_sesion"] > 2:
return "Usuarios activos con alta propensión a compra"
if row["engagement_por_email"] > 1.5 and row["recencia_fichas"] > 2000:
return "Usuarios poco exploradores con baja intencion de compra"
return "Usuarios de muy baja intención de compra"
cluster_summary["perfil"] = cluster_summary.apply(classify_cluster, axis=1)
# Mostrar tabla final ordenada por tasa de compra
cluster_summary = cluster_summary.sort_values("tasa_compra_pct", ascending=False)
cluster_summary
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | total_sesiones | tasa_compra | tasa_compra_pct | perfil | |
|---|---|---|---|---|---|---|---|---|---|
| cluster | |||||||||
| 1 | 10.18 | 3.15 | 1097.01 | 1.55 | 2.27 | 4.44 | 0.029635 | 2.96 | Usuarios activos con alta propensión a compra |
| 3 | 3.01 | 0.01 | 2298.75 | 1.53 | 1.53 | 1.95 | 0.004649 | 0.46 | Usuarios poco exploradores con baja intencion ... |
| 0 | 2.43 | 0.06 | 2243.18 | 0.00 | 1.40 | 1.65 | 0.003552 | 0.36 | Usuarios de muy baja intención de compra |
| 2 | 273.32 | 149.76 | 965.92 | 1.82 | 2.10 | 132.84 | 0.000000 | 0.00 | Usuarios intensivos no compradores |
📌 Conclusión
La primera segmentación con K-Means, basada en variables generales de actividad y navegación, revela una estructura débilmente separable desde un punto de vista geométrico y una elevada sensibilidad a valores extremos. No obstante, al incorporar la tasa de compra como criterio interpretativo ex post, los clusters adquieren un significado más claro en términos de propensión relativa a la conversión.
En particular, se identifica un grupo de usuarios activos con alta propensión a compra, cuya tasa de conversión se sitúa claramente por encima del promedio global del conjunto de datos. En contraste, emergen dos clusters caracterizados por baja actividad y baja propensión, con tasas de compra inferiores al 0,5 %, que agrupan usuarios con interacción limitada o esporádica.
De forma especialmente relevante, la segmentación detecta un grupo de usuarios intensivos no compradores, que concentra niveles extremadamente altos de actividad, exploración y sesiones, pero presenta una tasa de conversión nula. Este patrón sugiere la existencia de perfiles cuyo comportamiento intensivo no responde a una intención de compra clásica. Una hipótesis plausible es que se trate de usuarios con fines informativos, comparativos o profesionales, o bien de perfiles sensibles a fricciones no observadas en los datos disponibles (precio, disponibilidad, condiciones comerciales).
Desde una perspectiva aplicada, la identificación de este cluster resulta especialmente valiosa, ya que permite evitar una sobreestimación de la intención basada únicamente en volumen de actividad y abre la puerta a estrategias diferenciadas, como análisis específicos de fricción, segmentación excluyente de campañas o diseño de propuestas de valor adaptadas. Este hallazgo refuerza la idea de que no toda señal intensa es necesariamente una señal estratégica.
En conjunto, esta primera segmentación confirma que K-Means captura gradientes de comportamiento útiles, pero también pone de manifiesto sus limitaciones para separar de forma nítida perfiles de intención. Estos resultados justifican el uso de enfoques complementarios que incorporen variables categóricas y ajustes metodológicos, con el objetivo de obtener perfiles más interpretables y mejor alineados con los objetivos de explotación y análisis del modelo.
#!pip install gower hdbscan
Segmentacion con K-Prototypes (numéricas + categóricas)
df = df_final_cb_lgbm.copy()
# Variables a utilizar
numeric_cols = [
"total_clicks", "total_fichas_consultadas", "recencia_fichas",
"engagement_por_email", "clicks_por_sesion", "usuarios_que_consultan_misma_primera_ficha"
]
categorical_cols = ["canal", "tipo_usuario"]
all_cols = numeric_cols + categorical_cols
# Subset del dataset
X = df[all_cols].copy()
# Asegurar tipos correctos
for col in numeric_cols:
X[col] = pd.to_numeric(X[col], errors="coerce")
for col in categorical_cols:
X[col] = X[col].astype(str)
# Eliminar filas con nulos
X = X.dropna()
#!pip install kmodes
kPrototypesFile = "results/clustering/kprototypes_costs.pkl"
costs = []
K_range = range(2, 9)
if os.path.exists(kPrototypesFile):
costs = joblib.load(kPrototypesFile)
else:
# Muestreo razonable
X_sample = X.sample(30000, random_state=42)
cat_idx = [X_sample.columns.get_loc(c) for c in categorical_cols]
for k in K_range:
print(f"Calculando K-Prototypes para k={k}")
try:
kproto = KPrototypes(
n_clusters=k,
init='random', # MÁS ESTABLE para el codo
random_state=42,
n_init=2, # suficiente para exploración
verbose=0
)
kproto.fit_predict(X_sample.values, categorical=cat_idx)
costs.append(kproto.cost_)
except ValueError as e:
print(f"⚠️ k={k} falló: {e}")
costs.append(np.nan)
os.makedirs("results/clustering", exist_ok=True)
joblib.dump(costs, kPrototypesFile)
# Gráfica del codo
plt.figure(figsize=(8,5))
plt.plot(K_range, costs, marker='o')
plt.xlabel("Número de clusters")
plt.ylabel("Costo del modelo")
plt.title("Método del codo — K-Prototypes")
plt.show()
OUTPUT_DIR = "results/clustering"
MODEL_FILE = os.path.join(OUTPUT_DIR, "kprototypes_model.pkl")
CLUSTERS_FILE = os.path.join(OUTPUT_DIR, "kprototypes_clusters.pkl")
k = 4
categorical_idx = [X.columns.get_loc(c) for c in categorical_cols]
# ========================================================
# Crear carpeta si no existe
# ========================================================
os.makedirs(OUTPUT_DIR, exist_ok=True)
# ========================================================
# Entrenar solo si no existe el modelo guardado
# ========================================================
if os.path.exists(MODEL_FILE) and os.path.exists(CLUSTERS_FILE):
print("Modelo K-Prototypes encontrado. Cargando resultados...")
kproto = joblib.load(MODEL_FILE)
clusters = joblib.load(CLUSTERS_FILE)
else:
print("Entrenando modelo K-Prototypes (esto puede tardar varios minutos)...")
kproto = KPrototypes(
n_clusters=k,
init='Huang',
random_state=42,
n_init=3, # Ajustado por coste computacional
max_iter=50,
verbose=1
)
clusters = kproto.fit_predict(
X.values,
categorical=categorical_idx
)
# Guardar resultados
joblib.dump(kproto, MODEL_FILE)
joblib.dump(clusters, CLUSTERS_FILE)
print(f"Modelo guardado en: {MODEL_FILE}")
print(f"Clusters guardados en: {CLUSTERS_FILE}")
# ========================================================
# Añadir clusters al dataframe
# ========================================================
X["cluster"] = clusters
Modelo K-Prototypes encontrado. Cargando resultados...
desc = X.groupby("cluster")[numeric_cols].mean().round(2)
count = X.groupby("cluster").size().rename("n_usuarios")
summary = pd.concat([desc, count], axis=1)
display(summary)
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | n_usuarios | |
|---|---|---|---|---|---|---|---|
| cluster | |||||||
| 0 | 10.17 | 3.13 | 1026.56 | 1.56 | 2.20 | 6.39 | 25138 |
| 1 | 10.71 | 4.69 | 456.20 | 1.29 | 1.47 | 7.54 | 1358 |
| 2 | 9.49 | 3.04 | 1209.80 | 1.30 | 2.21 | 6.75 | 22405 |
| 3 | 2.80 | 0.00 | 2306.00 | 0.99 | 1.49 | 8.38 | 146264 |
for col in categorical_cols:
display(X.groupby("cluster")[col].value_counts(normalize=True).rename("proportion"))
cluster canal
0 Directorios 0.478757
SEO 0.297916
SEM 0.223327
1 Directorios 0.821060
SEO 0.147275
SEM 0.031664
2 Directorios 0.514082
SEO 0.273823
SEM 0.212096
3 Directorios 0.904775
SEO 0.075432
SEM 0.019793
Name: proportion, dtype: float64
cluster tipo_usuario
0 PF 0.855080
PJ 0.144920
1 PF 0.787923
PJ 0.212077
2 PF 0.849185
PJ 0.150815
3 PF 0.838292
PJ 0.161708
Name: proportion, dtype: float64
def perfil_cluster(row):
clicks = row["total_clicks"]
fichas = row["total_fichas_consultadas"]
engagement = row["engagement_por_email"]
sesiones = row["clicks_por_sesion"]
recencia = row["recencia_fichas"]
if clicks > 10 and engagement > 1 and recencia < 1000:
return "Usuarios altamente activos y multicanal"
if clicks < 5 and fichas == 0 and engagement <1:
return "Usuarios de baja intención o exploración mínima"
if engagement > 1.2 and clicks < 5:
return "Usuarios sensibles al canal email"
return "Actividad media con exploración moderada"
summary["perfil"] = summary.apply(perfil_cluster, axis=1)
summary
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | n_usuarios | perfil | |
|---|---|---|---|---|---|---|---|---|
| cluster | ||||||||
| 0 | 10.17 | 3.13 | 1026.56 | 1.56 | 2.20 | 6.39 | 25138 | Actividad media con exploración moderada |
| 1 | 10.71 | 4.69 | 456.20 | 1.29 | 1.47 | 7.54 | 1358 | Usuarios altamente activos y multicanal |
| 2 | 9.49 | 3.04 | 1209.80 | 1.30 | 2.21 | 6.75 | 22405 | Actividad media con exploración moderada |
| 3 | 2.80 | 0.00 | 2306.00 | 0.99 | 1.49 | 8.38 | 146264 | Usuarios de baja intención o exploración mínima |
# One-hot para visualización
enc = OneHotEncoder(drop="first", sparse_output=False)
cat_encoded = enc.fit_transform(X[categorical_cols])
X_vis = np.hstack([X[numeric_cols].values, cat_encoded])
# PCA
pca = PCA(n_components=2)
pcs = pca.fit_transform(X_vis)
X["PC1"] = pcs[:,0]
X["PC2"] = pcs[:,1]
plt.figure(figsize=(8,6))
sns.scatterplot(data=X, x="PC1", y="PC2", hue="cluster", palette="viridis", alpha=0.6)
plt.title("Clusters de usuarios — PCA + K-Prototypes")
plt.show()
#!pip install umap-learn
kPrototypesUmapFile = "results/clustering/kprototypes_Umap_emb.pkl"
K_range = range(2, 9)
if os.path.exists(kPrototypesUmapFile):
umap_emb = joblib.load(kPrototypesUmapFile)
print("Archivos cargados")
else:
reducer = umap.UMAP(random_state=42)
umap_emb = reducer.fit_transform(X_vis)
joblib.dump(umap_emb, kPrototypesUmapFile)
X["UMAP1"] = umap_emb[:,0]
X["UMAP2"] = umap_emb[:,1]
plt.figure(figsize=(8,6))
sns.scatterplot(data=X, x="UMAP1", y="UMAP2", hue="cluster", palette="viridis", alpha=0.6)
plt.title("Clusters de usuarios — UMAP + K-Prototypes")
plt.show()
Archivos cargados
# ============================================================
# Tabla final de clusters K-Prototypes con tasa de compra
# ============================================================
# Partimos del dataframe original
df_clustered = df_final_cb_lgbm.copy()
# Añadir cluster calculado (alineado con X tras dropna)
df_clustered = df_clustered.loc[X.index].copy()
df_clustered["cluster"] = clusters
# --- Resumen numérico por cluster ---
numeric_summary = (
df_clustered
.groupby("cluster")[numeric_cols]
.mean()
.round(2)
)
# --- Tamaño del cluster ---
cluster_size = (
df_clustered
.groupby("cluster")
.size()
.rename("n_usuarios")
)
# --- Tasa de compra ---
conversion = (
df_clustered
.groupby("cluster")["es_cliente"]
.mean()
.rename("tasa_compra")
)
# --- Unir todo ---
cluster_summary = (
numeric_summary
.merge(cluster_size, left_index=True, right_index=True)
.merge(conversion, left_index=True, right_index=True)
)
cluster_summary["tasa_compra_pct"] = (cluster_summary["tasa_compra"] * 100).round(2)
# --- Perfil semántico (mismo criterio que antes) ---
def perfil_cluster(row):
clicks = row["total_clicks"]
fichas = row["total_fichas_consultadas"]
engagement = row["engagement_por_email"]
sesiones = row["clicks_por_sesion"]
recencia = row["recencia_fichas"]
if clicks > 10 and engagement > 1 and recencia < 1000:
return "Compradores, intensivos y multicanal"
if clicks < 5 and fichas == 0 and engagement < 1:
return "Usuarios pasivos de baja conversión"
if engagement > 1.2 and clicks < 5:
return "Usuarios sensibles al canal email"
return "Usuarios exploradores con intención latente"
cluster_summary["perfil"] = cluster_summary.apply(perfil_cluster, axis=1)
# --- Ordenar por tasa de compra ---
cluster_summary = cluster_summary.sort_values(
"tasa_compra_pct", ascending=False
)
cluster_summary
| total_clicks | total_fichas_consultadas | recencia_fichas | engagement_por_email | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | n_usuarios | tasa_compra | tasa_compra_pct | perfil | |
|---|---|---|---|---|---|---|---|---|---|---|
| cluster | ||||||||||
| 1 | 10.71 | 4.69 | 456.20 | 1.29 | 1.47 | 7.54 | 1358 | 0.074374 | 7.44 | Compradores, intensivos y multicanal |
| 2 | 9.49 | 3.04 | 1209.80 | 1.30 | 2.21 | 6.75 | 22405 | 0.029011 | 2.90 | Usuarios exploradores con intención latente |
| 0 | 10.17 | 3.13 | 1026.56 | 1.56 | 2.20 | 6.39 | 25138 | 0.027846 | 2.78 | Usuarios exploradores con intención latente |
| 3 | 2.80 | 0.00 | 2306.00 | 0.99 | 1.49 | 8.38 | 146264 | 0.003644 | 0.36 | Usuarios pasivos de baja conversión |
📌 Conclusión
La segmentación mediante K-Prototypes, al incorporar simultáneamente variables numéricas de comportamiento y variables categóricas como canal y tipo_usuario, permite identificar perfiles con diferencias claras en términos de propensión real a la compra. A diferencia del primer intento con K-Means, esta aproximación revela tres clusters cuya tasa de conversión supera ampliamente el promedio global del dataset, lo que confirma la utilidad del enfoque para detectar segmentos de alto valor.
Destaca especialmente el grupo de compradores intensivos multicanal, que presenta una tasa de compra cercana al 7,5 %. Este perfil combina actividad sostenida, recencia favorable y engagement consistente, lo que lo convierte en un segmento prioritario tanto desde el punto de vista predictivo como de negocio.
Adicionalmente, emergen dos clusters de usuarios activos con intención latente, caracterizados por actividad y exploración moderadas pero con tasas de compra cercanas al 3 %, casi tres veces superiores a la media global. Estos perfiles representan oportunidades claras para estrategias de activación, nurturing o personalización, ya que muestran señales suficientes de interés aunque no consolidadas.
En contraste, el cluster mayoritario agrupa a usuarios pasivos de muy baja conversión, con una tasa de compra marginal. Este segmento refuerza la idea de que la mera presencia en el sistema no implica intención, y que las señales categóricas por sí solas no compensan la ausencia de comportamiento relevante.
En conjunto, estos resultados indican que la inclusión de variables categóricas no transforma radicalmente la estructura del espacio de usuarios, pero sí contribuye a aislar segmentos con valor de negocio significativo. El análisis refuerza la conclusión de que la intención de compra está dominada por patrones de comportamiento, mientras que el canal actúa como un modulador secundario que ayuda a perfilar mejor los segmentos de alta conversión.
Segmentación refinada con K-Means
df = df_final_cb_lgbm.copy()
# Variables a utilizar
numeric_cols = [
"total_clicks", "total_fichas_consultadas", "recencia_fichas",
"engagement_por_email", "clicks_por_sesion", "usuarios_que_consultan_misma_primera_ficha"
]
# ========================================================
# NUEVA RONDA DE SEGMENTACIÓN — k = 3 (SOLO NUMÉRICAS)
# K-MEANS
# ========================================================
import os
import joblib
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
# ========================================================
# 1. Configuración
# ========================================================
OUTPUT_DIR = "results/clustering"
MODEL_FILE = os.path.join(OUTPUT_DIR, "kmeans_k3_round2_model.pkl")
CLUSTERS_FILE = os.path.join(OUTPUT_DIR, "kmeans_k3_round2_clusters.pkl")
os.makedirs(OUTPUT_DIR, exist_ok=True)
numeric_cols_cluster = [
"recencia_fichas",
"engagement_por_email",
"clicks_por_sesion",
"usuarios_que_consultan_misma_primera_ficha",
"mes_registro"
]
categorical_cols = ["canal", "tipo_usuario"]
# Subset del dataset
X = df_final_cb_lgbm[numeric_cols_cluster+categorical_cols].copy()
# Asegurar tipos correctos
for col in numeric_cols_cluster:
X[col] = pd.to_numeric(X[col], errors="coerce")
# Eliminar filas con nulos
X = X.dropna()
# ========================================================
# 2. Preparación de datos
# ========================================================
X_cluster = X[numeric_cols_cluster].copy()
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_cluster)
# ========================================================
# 3. Entrenamiento K-Means
# ========================================================
if os.path.exists(MODEL_FILE) and os.path.exists(CLUSTERS_FILE):
print("Modelo K-Means encontrado. Cargando resultados...")
kmeans = joblib.load(MODEL_FILE)
clusters = joblib.load(CLUSTERS_FILE)
else:
print("Entrenando K-Means (k=3, variables ortogonales)...")
kmeans = KMeans(
n_clusters=3,
n_init=10,
max_iter=300,
random_state=42
)
clusters = kmeans.fit_predict(X_scaled)
joblib.dump(kmeans, MODEL_FILE)
joblib.dump(clusters, CLUSTERS_FILE)
print("Modelo y clusters guardados")
# Añadir clusters al dataframe
X["cluster_k3_round2"] = clusters
# ========================================================
# 4. Perfilado de clusters
# ========================================================
display(X["cluster_k3_round2"].value_counts(normalize=True))
cluster_profile = (
X.groupby("cluster_k3_round2")[numeric_cols_cluster]
.mean()
.round(2)
)
display(cluster_profile)
# ========================================================
# 5. Caracterización (NO usada para clustering)
# ========================================================
for col in ["canal", "tipo_usuario"]:
if col in X.columns:
display(
X.groupby("cluster_k3_round2")[col]
.value_counts(normalize=True)
.rename("proportion")
)
# ========================================================
# 6. Visualización PCA
# ========================================================
pca = PCA(n_components=2, random_state=42)
pcs = pca.fit_transform(X_scaled)
X["PC1_k3"] = pcs[:, 0]
X["PC2_k3"] = pcs[:, 1]
plt.figure(figsize=(8, 6))
sns.scatterplot(
data=X,
x="PC1_k3",
y="PC2_k3",
hue="cluster_k3_round2",
palette="viridis",
alpha=0.6
)
plt.title("Segmentación de usuarios — PCA + K-Means (k=3)")
plt.show()
# ========================================================
# 7. Guardar scaler
# ========================================================
joblib.dump(
scaler,
os.path.join(OUTPUT_DIR, "kmeans_k3_round2_scaler.pkl")
)
Modelo K-Means encontrado. Cargando resultados...
cluster_k3_round2 0 0.730782 1 0.254477 2 0.014741 Name: proportion, dtype: float64
| recencia_fichas | engagement_por_email | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | mes_registro | |
|---|---|---|---|---|---|
| cluster_k3_round2 | |||||
| 0 | 2303.39 | 0.96 | 1.47 | 5.49 | 6.51 |
| 1 | 1128.19 | 1.49 | 2.24 | 5.63 | 6.62 |
| 2 | 2178.36 | 1.36 | 1.59 | 168.66 | 6.52 |
cluster_k3_round2 canal
0 Directorios 0.902933
SEO 0.076720
SEM 0.020347
1 Directorios 0.511930
SEO 0.279130
SEM 0.208940
2 Directorios 0.973236
SEO 0.018074
SEM 0.008690
Name: proportion, dtype: float64
cluster_k3_round2 tipo_usuario
0 PF 0.837586
PJ 0.162414
1 PF 0.850378
PJ 0.149622
2 PF 0.872437
PJ 0.127563
Name: proportion, dtype: float64
['results/clustering\\kmeans_k3_round2_scaler.pkl']
# ============================================================
# Tabla final de clusters — K-Means refinado (k=3) con compra
# ============================================================
# Alinear con el dataset original
df_clustered = df_final_cb_lgbm.loc[X.index].copy()
df_clustered["cluster_k3_round2"] = clusters
# --- Resumen numérico por cluster ---
numeric_summary = (
df_clustered
.groupby("cluster_k3_round2")[numeric_cols_cluster]
.mean()
.round(2)
)
# --- Tamaño del cluster ---
cluster_size = (
df_clustered
.groupby("cluster_k3_round2")
.size()
.rename("n_usuarios")
)
# --- Tasa de compra ---
conversion = (
df_clustered
.groupby("cluster_k3_round2")["es_cliente"]
.mean()
.rename("tasa_compra")
)
# --- Unir todo ---
cluster_summary_k3 = (
numeric_summary
.merge(cluster_size, left_index=True, right_index=True)
.merge(conversion, left_index=True, right_index=True)
)
cluster_summary_k3["tasa_compra_pct"] = (cluster_summary_k3["tasa_compra"] * 100).round(2)
# --- Perfil semántico orientativo ---
def perfil_cluster_k3(row):
if row["engagement_por_email"] > 1.4 and row["recencia_fichas"] < 1200:
return "Usuarios activos con intención latente de compra"
if row["usuarios_que_consultan_misma_primera_ficha"] > 50:
return "Exploradores intensivos no compradores"
return "Usuarios pasivos de baja conversión"
cluster_summary_k3["perfil"] = cluster_summary_k3.apply(perfil_cluster_k3, axis=1)
# --- Ordenar por tasa de compra ---
cluster_summary_k3 = cluster_summary_k3.sort_values(
"tasa_compra_pct", ascending=False
)
cluster_summary_k3
| recencia_fichas | engagement_por_email | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | mes_registro | n_usuarios | tasa_compra | tasa_compra_pct | perfil | |
|---|---|---|---|---|---|---|---|---|---|
| cluster_k3_round2 | |||||||||
| 1 | 1128.19 | 1.49 | 2.24 | 5.63 | 6.62 | 49665 | 0.028068 | 2.81 | Usuarios activos con intención latente de compra |
| 0 | 2303.39 | 0.96 | 1.47 | 5.49 | 6.51 | 142623 | 0.004067 | 0.41 | Usuarios pasivos de baja conversión |
| 2 | 2178.36 | 1.36 | 1.59 | 168.66 | 6.52 | 2877 | 0.003476 | 0.35 | Exploradores intensivos no compradores |
📌 Conclusión
Al reducir el número de variables y seleccionar únicamente aquellas alineadas con los resultados del análisis SHAP, la segmentación mediante K-Means con k = 3 genera grupos más coherentes desde el punto de vista comportamental y más consistentes con la propensión real a la compra. En comparación con la segmentación inicial, esta aproximación mitiga el efecto de valores extremos y facilita una interpretación más clara de los patrones dominantes.
Se identifica un primer grupo de usuarios activos con intención latente de compra, que presenta una tasa de conversión cercana al 3 %, claramente superior a la media global. Este perfil combina una recencia relativamente favorable, un nivel de engagement moderado y un patrón de navegación equilibrado, lo que lo sitúa como el segmento con mayor potencial dentro de esta segmentación.
En contraste, el grupo mayoritario corresponde a usuarios pasivos con muy baja conversión, caracterizados por una recencia elevada, bajo engagement y una tasa de compra residual. Este cluster representa el comportamiento base de la población y refuerza la idea de que la ausencia de señales recientes limita de forma estructural la probabilidad de conversión.
Finalmente, emerge un grupo reducido pero bien definido de exploradores intensivos no compradores, con un volumen de exploración extremadamente alto que no se traduce en compra. Este perfil confirma que la intensidad de uso del catálogo no implica necesariamente intención de compra y pone de manifiesto la existencia de comportamientos predominantemente informativos o comparativos que el modelo es capaz de diferenciar.
En conjunto, esta segmentación refinada respalda los perfiles identificados en el análisis SHAP local y refuerza la interpretación de que la intención de compra se distribuye de forma continua, con transiciones claras entre usuarios pasivos, usuarios activos con intención latente y exploradores intensivos. Por este motivo, el clustering se emplea como una herramienta explicativa y contextual, complementaria al modelo predictivo, y no como un mecanismo de segmentación operativa independiente.
Busqueda de cluteres atípicos
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
import seaborn as sns
# =====================================================
# 1. Selección de variables
# =====================================================
dbscan_features = [
"total_clicks",
"clicks_por_sesion",
"usuarios_que_consultan_misma_primera_ficha",
"recencia_fichas",
"total_sesiones"
]
X_db = df_final_cb_lgbm[dbscan_features].copy()
# =====================================================
# 2. Submuestreo para velocidad
# =====================================================
X_sample = X_db.sample(n=30000, random_state=42)
# =====================================================
# 3. Escalado
# =====================================================
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_sample)
# =====================================================
# 4. DBSCAN
# =====================================================
dbscan = DBSCAN(
eps=1.2, # empieza aquí; si no detecta ruido, baja a 1.2
min_samples=50, # grupos suficientemente densos
n_jobs=-1
)
labels = dbscan.fit_predict(X_scaled)
X_sample["dbscan_cluster"] = labels
# =====================================================
# 5. Resumen rápido
# =====================================================
print("Distribución de clusters DBSCAN:")
print(X_sample["dbscan_cluster"].value_counts().sort_index())
n_noise = (labels == -1).sum()
print(f"\nUsuarios marcados como ruido (-1): {n_noise} ({n_noise/len(labels):.2%})")
# =====================================================
# 6. Visualización rápida (PCA)
# =====================================================
pca = PCA(n_components=2, random_state=42)
pcs = pca.fit_transform(X_scaled)
X_sample["PC1"] = pcs[:,0]
X_sample["PC2"] = pcs[:,1]
plt.figure(figsize=(8,6))
sns.scatterplot(
data=X_sample,
x="PC1",
y="PC2",
hue="dbscan_cluster",
palette="tab10",
alpha=0.4,
legend=False
)
plt.title("DBSCAN — detección de patrones densos y ruido")
plt.show()
Distribución de clusters DBSCAN: dbscan_cluster -1 269 0 22209 1 7426 2 96 Name: count, dtype: int64 Usuarios marcados como ruido (-1): 269 (0.90%)
# =====================================================
# 7. Tabla resumen DBSCAN con dimensión de compra
# =====================================================
# Añadir variable objetivo al sample (alineando índices)
X_sample = X_sample.join(
df_final_cb_lgbm.loc[X_sample.index, "es_cliente"]
)
# Resumen numérico por cluster
numeric_summary = (
X_sample
.groupby("dbscan_cluster")[dbscan_features]
.mean()
.round(2)
)
# Número de usuarios por cluster
counts = (
X_sample
.groupby("dbscan_cluster")
.size()
.rename("n_usuarios")
)
# Tasa de compra por cluster
purchase_rate = (
X_sample
.groupby("dbscan_cluster")["es_cliente"]
.mean()
.rename("tasa_compra")
)
# Unir todo
dbscan_summary = pd.concat(
[numeric_summary, counts, purchase_rate],
axis=1
)
# Añadir tasa de compra en porcentaje
dbscan_summary["tasa_compra_pct"] = (dbscan_summary["tasa_compra"] * 100).round(2)
# Ordenar para facilitar lectura (ruido al final)
dbscan_summary = dbscan_summary.sort_index()
display(dbscan_summary)
| total_clicks | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | recencia_fichas | total_sesiones | n_usuarios | tasa_compra | tasa_compra_pct | |
|---|---|---|---|---|---|---|---|---|
| dbscan_cluster | ||||||||
| -1 | 27.22 | 2.85 | 93.33 | 1593.64 | 12.42 | 269 | 0.104089 | 10.41 |
| 0 | 2.78 | 1.49 | 6.54 | 2306.00 | 1.81 | 22209 | 0.002657 | 0.27 |
| 1 | 9.42 | 2.17 | 5.82 | 1095.08 | 4.17 | 7426 | 0.029491 | 2.95 |
| 2 | 3.12 | 1.56 | 234.42 | 2306.00 | 2.01 | 96 | 0.000000 | 0.00 |
dbscan_summary = dbscan_summary.copy()
def label_dbscan_cluster(row, cluster_id):
# Ruido (-1): alta conversión inesperada
if cluster_id == -1:
return "Usuarios atípicos de alta conversión"
# Cluster 1: actividad media + buena conversión
if row["tasa_compra_pct"] > 2:
return "Usuarios activos con intención de compra"
# Cluster 2: exploración extrema sin compra
if row["usuarios_que_consultan_misma_primera_ficha"] > 100:
return "Exploradores intensivos no compradores"
# Cluster 0: baja actividad y baja conversión
return "Usuarios pasivos de muy baja intención"
# Aplicar etiquetas
dbscan_summary["perfil"] = [
label_dbscan_cluster(row, idx)
for idx, row in dbscan_summary.iterrows()
]
# Reordenar columnas para claridad
dbscan_summary = dbscan_summary[
[
"perfil",
"n_usuarios",
"tasa_compra",
"tasa_compra_pct",
"total_clicks",
"clicks_por_sesion",
"usuarios_que_consultan_misma_primera_ficha",
"recencia_fichas",
"total_sesiones",
]
]
dbscan_summary
| perfil | n_usuarios | tasa_compra | tasa_compra_pct | total_clicks | clicks_por_sesion | usuarios_que_consultan_misma_primera_ficha | recencia_fichas | total_sesiones | |
|---|---|---|---|---|---|---|---|---|---|
| dbscan_cluster | |||||||||
| -1 | Usuarios atípicos de alta conversión | 269 | 0.104089 | 10.41 | 27.22 | 2.85 | 93.33 | 1593.64 | 12.42 |
| 0 | Usuarios pasivos de muy baja intención | 22209 | 0.002657 | 0.27 | 2.78 | 1.49 | 6.54 | 2306.00 | 1.81 |
| 1 | Usuarios activos con intención de compra | 7426 | 0.029491 | 2.95 | 9.42 | 2.17 | 5.82 | 1095.08 | 4.17 |
| 2 | Exploradores intensivos no compradores | 96 | 0.000000 | 0.00 | 3.12 | 1.56 | 234.42 | 2306.00 | 2.01 |
📌 Conclusión — DBSCAN
La aplicación de DBSCAN permite explorar la existencia de patrones densos y comportamientos atípicos que no quedan adecuadamente representados mediante técnicas de clustering particional como K-Means o K-Prototypes. Dado su mayor coste computacional, el análisis se realiza sobre una muestra representativa de 30 000 usuarios, suficiente para identificar estructuras locales relevantes en el espacio de comportamiento.
El resultado más destacado es la detección de un conjunto de usuarios atípicos con muy alta propensión a la compra, clasificados como ruido por el algoritmo. Este grupo presenta una tasa de conversión superior al 10 %, más de diez veces la media global del dataset, lo que indica que DBSCAN está capturando patrones de altísimo valor desde el punto de vista de negocio. Lejos de representar anomalías irrelevantes, estos usuarios concentran señales de intención extremadamente fuertes que no forman clusters densos tradicionales debido a su baja frecuencia.
De forma consistente con los análisis previos, DBSCAN también identifica un grupo de usuarios activos con intención de compra, con una tasa cercana al 3 %, alineada con los clusters de mayor propensión detectados mediante K-Means y K-Prototypes. Esta convergencia refuerza la robustez y estabilidad de dicho perfil a través de metodologías distintas.
Asimismo, el algoritmo vuelve a aislar un patrón ya observado en segmentaciones anteriores: un grupo reducido de exploradores intensivos no compradores, caracterizados por una actividad extremadamente elevada que no se traduce en conversión. Este perfil confirma que la intensidad de navegación, por sí sola, no implica intención de compra y valida una de las conclusiones clave del análisis global del modelo.
El cluster mayoritario, por su parte, corresponde a usuarios pasivos con muy baja intención, con tasas de compra residuales, reforzando la separación estructural entre comportamiento activo y pasivo observada a lo largo de todo el estudio.
Aunque DBSCAN se aplica sobre una muestra, su utilidad práctica reside en la capacidad de detectar regiones de alta densidad de conversión y patrones atípicos. En un entorno productivo, este enfoque podría extenderse al conjunto completo de datos mediante asignación aproximada al cluster más cercano o mediante la incorporación de una señal binaria adicional (pertenencia a región densa / ruido) integrada en el modelo predictivo.
En conjunto, DBSCAN no se utiliza como herramienta de segmentación operativa, sino como un mecanismo exploratorio que aporta evidencia adicional sobre la naturaleza no lineal, altamente heterogénea y minoritaria de determinados patrones de intención de compra.
Insight 3 — Robustez, estabilidad y contexto en las señales predictivas del modelo
Más allá del ranking de importancia de variables, el análisis conjunto de importancia y estabilidad permite profundizar en la naturaleza de las señales que el modelo utiliza para predecir la compra. Mientras que la importancia mide la magnitud del efecto de una variable sobre la predicción, la estabilidad evalúa la consistencia de dicho efecto frente a variaciones en la muestra de datos. Esta distinción resulta clave para interpretar la robustez y capacidad de generalización del modelo.
El análisis del plano Importancia–Estabilidad revela la existencia de variables estratégicas, caracterizadas por una alta relevancia predictiva y un comportamiento consistente entre iteraciones bootstrap. En este grupo destaca engagement_por_email, cuya contribución es elevada en métricas globales como SHAP y CatBoost, y presenta además una estabilidad relativamente alta. Esto sugiere que esta variable captura una relación estructural entre la interacción del usuario y la probabilidad de compra, constituyendo una señal fiable y aplicable a decisiones de negocio de alcance general.
En contraste, se identifican variables de carácter táctico, como usuarios_que_consultan_misma_primera_ficha, que combinan una alta importancia con una mayor inestabilidad. Estas variables aportan un elevado poder explicativo en determinados patrones de comportamiento, pero su efecto no es homogéneo en toda la población. Su relevancia emerge con especial claridad en análisis locales y segmentados, lo que indica que su utilidad es mayor cuando se interpretan en contexto o en combinación con otras señales.
Las variables con importancia y estabilidad moderadas actúan como señales de soporte, reforzando la predicción cuando acompañan a variables estratégicas, mientras que aquellas con baja importancia y baja estabilidad presentan una contribución limitada al modelo. Este resultado sugiere que el modelo aprende principalmente a partir de patrones consistentes de interacción y recencia, y no de atributos aislados o puramente estáticos.
Finalmente, la identificación de perfiles atípicos de alta conversión mediante técnicas como DBSCAN refuerza esta interpretación: el modelo es capaz de capturar señales relevantes incluso en subgrupos minoritarios, siempre que estas se manifiesten de forma consistente en el comportamiento observado. Al mismo tiempo, el análisis pone de manifiesto qué el modelo no aprende: factores exógenos no observables o patrones extremadamente volátiles quedan fuera de su capacidad predictiva.
En conjunto, este insight permite interpretar el modelo no solo en términos de qué variables utiliza, sino también de cómo y en qué contexto dichas variables aportan valor, aportando una base sólida para la toma de decisiones y la discusión de limitaciones y próximos pasos.
📌 Conclusiones del bloque de Insights
- El análisis conjunto de importancias confirma que el modelo fundamenta sus predicciones principalmente en señales comportamentales de interacción y recencia. Estas variables concentran el peso estructural del razonamiento predictivo, mientras que los atributos categóricos y contextuales actúan como información complementaria.
- La incorporación explícita de la dimensión de estabilidad permite diferenciar entre señales estratégicas, caracterizadas por una contribución consistente y generalizable, y señales tácticas, cuyo valor predictivo depende del contexto o del perfil de usuario. Esta distinción resulta clave para interpretar la robustez real del modelo.
- El análisis de SHAP local evidencia que el modelo no razona de forma uniforme, sino que combina las mismas variables con pesos y direcciones distintas según el patrón individual. De este modo, emergen perfiles explicativos diferenciados de alta intención, exploración mixta y baja intención, que reflejan decisiones basadas en configuraciones específicas de señales y no en reglas globales rígidas.
- La segmentación mediante técnicas de clustering refuerza esta lectura y muestra que la intención de compra no se organiza en grupos perfectamente separados, sino en un espacio continuo de comportamiento. No obstante, al incorporar la dimensión de conversión de forma interpretativa, se identifican clusters con tasas de compra muy superiores a la media, así como grupos de actividad intensiva sin conversión.
- La detección de subgrupos minoritarios de alta conversión, especialmente mediante DBSCAN, pone de manifiesto que algunos perfiles de alto valor no se concentran en clusters densos, sino que aparecen como patrones atípicos pero altamente informativos. De forma complementaria, la identificación consistente de segmentos con conversión nula o residual aporta una señal clara sobre zonas de baja rentabilidad estructural.
- En conjunto, los insights obtenidos muestran que el modelo captura con eficacia los patrones globales asociados a la compra, pero que una parte sustancial de su valor interpretativo emerge al analizar cómo se distribuye la conversión entre distintos perfiles de comportamiento. La segmentación no supervisada no replica el razonamiento interno del modelo, pero actúa como una herramienta explicativa complementaria que permite contextualizar sus predicciones, identificar oportunidades de alto impacto y delimitar segmentos estructuralmente poco propensos a la conversión. Estas conclusiones proporcionan una base sólida tanto para la explotación del modelo como para futuras extensiones del sistema.
Aplicaciones de negocio del modelo predictivo
El modelo desarrollado no se concibe únicamente como un ejercicio predictivo, sino como una herramienta operativa orientada a la toma de decisiones en un entorno de comercio electrónico. La combinación de un alto poder discriminatorio, una gestión flexible de umbrales de probabilidad y un análisis profundo de interpretabilidad permite articular distintas aplicaciones de negocio, adaptadas a objetivos y restricciones operativas concretas, y coherentes con los patrones de comportamiento identificados en los análisis previos.
Priorización y focalización de acciones comerciales
El rendimiento alcanzado por el modelo —con un PR AUC de 0.477 y un Lift cercano a 7.7 en el primer decil— habilita una priorización eficaz de usuarios con alta probabilidad de compra. En términos operativos, esto permite concentrar los esfuerzos comerciales en el subconjunto de usuarios con mayor retorno esperado, reduciendo el coste asociado a acciones indiscriminadas.
En particular, la tasa de conversión observada en el primer decil (≈7.8%) multiplica por más de siete la conversión media del conjunto de usuarios, lo que justifica el uso del modelo como sistema de ranking continuo para campañas de activación, remarketing o personalización de ofertas, en lugar de como una decisión binaria rígida.
Gestión dinámica de campañas mediante umbrales de probabilidad
El análisis de umbrales de probabilidad demuestra que el modelo puede configurarse dinámicamente sin necesidad de reentrenamiento, adaptándose a distintos escenarios de negocio. Umbrales bajos permiten maximizar la captación y la cobertura, mientras que umbrales altos priorizan la precisión y minimizan las falsas alarmas.
- Estrategias de alcance amplio: umbrales orientados a alto recall permiten identificar una gran proporción de compradores potenciales, adecuados para campañas de bajo coste unitario.
- Estrategias equilibradas: el umbral por defecto ofrece un compromiso estable entre captura y eficiencia operativa, adecuado para la mayoría de escenarios recurrentes.
- Estrategias restrictivas: umbrales asociados a alta precisión o F1 máximo permiten actuar únicamente sobre usuarios con probabilidad muy elevada de compra, reduciendo al mínimo el riesgo de impacto innecesario.
Esta capacidad de ajuste convierte al modelo en una herramienta flexible que puede reutilizarse en múltiples contextos operativos sin necesidad de reentrenamiento, reduciendo costes y tiempos de adaptación.
Explotación de perfiles y segmentación para maximizar el valor
Los análisis de interpretabilidad y segmentación revelan que la probabilidad de compra no se distribuye de forma homogénea entre los usuarios, sino que se concentra en perfiles específicos de comportamiento. La identificación de clusters con tasas de conversión significativamente superiores a la media permite refinar las estrategias de activación más allá del ranking individual.
De especial interés es la detección de subgrupos minoritarios con comportamientos atípicos pero altamente rentables, identificados mediante técnicas como DBSCAN. Estos perfiles, que no encajan en los patrones mayoritarios, concentran tasas de conversión excepcionalmente altas y representan oportunidades claras para acciones específicas de alto impacto.
De forma complementaria, la identificación consistente de segmentos con conversión nula o residual aporta una señal operativa relevante para excluir o limitar la inversión sobre usuarios estructuralmente poco propensos a la compra, mejorando la eficiencia global del sistema.
Cabe destacar que estos grupos se identifican a posteriori, sin utilizar las predicciones del modelo supervisado, y actúan como una capa analítica complementaria para contextualizar y explotar mejor los scores generados.
Apoyo a la toma de decisiones estratégicas
Más allá de la predicción individual, el conjunto de insights obtenidos permite comprender qué señales aprende el modelo, cuáles son robustas y cuáles dependen del contexto. Esta información resulta valiosa para alinear decisiones de negocio, diseño de campañas y priorización de recursos con patrones reales de comportamiento observados en los datos.
En conjunto, el sistema propuesto no solo predice quién es más probable que compre, sino que proporciona un marco analítico para decidir a quién, cuándo y con qué intensidad actuar, maximizando el retorno esperado de las acciones comerciales y reduciendo la inversión en segmentos estructuralmente poco rentables.
Limitaciones y riesgos del enfoque propuesto
A pesar de los buenos resultados obtenidos en términos de rendimiento predictivo e interpretabilidad, el sistema desarrollado presenta una serie de limitaciones y riesgos que conviene analizar de forma explícita. Identificarlos no solo permite contextualizar los resultados, sino que aporta una visión crítica y realista sobre el alcance y las condiciones de validez del modelo.
Sesgos de datos y cobertura incompleta
El modelo se entrena sobre datos históricos de comportamiento de usuarios en un entorno de comercio electrónico. Como consecuencia, su capacidad predictiva depende directamente de la calidad, representatividad y cobertura de dichos datos. Usuarios con poca interacción, nuevos usuarios o perfiles con comportamientos no observados previamente pueden quedar infrarepresentados en el proceso de aprendizaje.
Asimismo, ciertas señales relevantes para la decisión de compra —como motivaciones externas, contexto temporal específico o factores exógenos— no están recogidas en el dataset, lo que limita la capacidad del modelo para capturar la totalidad de los determinantes reales del comportamiento del usuario.
Riesgo de sobreajuste y generalización
Aunque el uso de validación cruzada, regularización y métricas orientadas a la clase minoritaria mitiga el riesgo de sobreajuste, este no puede descartarse completamente. El modelo final, optimizado para maximizar el rendimiento global, aprende patrones dominantes presentes en los datos de entrenamiento, lo que puede reducir su capacidad de generalización ante cambios en el comportamiento de los usuarios o en las dinámicas del negocio.
Este riesgo es especialmente relevante en un entorno dinámico como el comercio electrónico, donde los patrones de navegación, los canales de captación y las estrategias comerciales pueden evolucionar con rapidez.
Dependencia de señales compuestas y contexto de exposición
Una limitación relevante del modelo reside en la interpretación de variables compuestas como
engagement_por_email. Esta variable combina información sobre la exposición del usuario al canal email
(a través de la clasificación bondad_email) y su respuesta posterior en forma de clicks.
Un valor distinto de cero implica que el usuario ha recibido un email clasificado como de alta calidad
y ha interactuado con él.
Por tanto, su elevada importancia no debe interpretarse únicamente como un efecto del número de clicks, sino como una señal estructural que distingue usuarios expuestos a comunicaciones relevantes frente a aquellos que no lo están. Esto introduce una dependencia implícita del modelo respecto a las estrategias de comunicación existentes, lo que puede generar sesgos si dichas estrategias cambian o si el canal email no se aplica de forma homogénea a toda la población.
Variables sensibles y consideraciones éticas
Aunque el modelo no utiliza variables explícitamente sensibles desde un punto de vista legal o ético, sí incorpora señales de comportamiento y canal que podrían correlacionar indirectamente con características del usuario no observadas. Es necesario, por tanto, extremar la cautela en el uso operativo del modelo para evitar decisiones automatizadas que puedan generar efectos discriminatorios no deseados.
El uso del modelo debe entenderse como un sistema de apoyo a la decisión, y no como un mecanismo autónomo de exclusión o priorización irreversible de usuarios.
Qué no aprende el modelo
El análisis de interpretabilidad y segmentación pone de manifiesto que el modelo, optimizado para capturar patrones globales, puede infravalorar o diluir la señal asociada a perfiles minoritarios con comportamientos extremos pero altamente efectivos en términos de conversión. Estos perfiles, detectados a posteriori mediante técnicas de clustering y DBSCAN, no siempre influyen de forma significativa en la función de decisión global del modelo.
Este comportamiento es coherente con la optimización de métricas agregadas, pero implica que ciertas oportunidades de alto valor pueden no ser plenamente explotadas si se confía exclusivamente en la predicción individual. Reconocer esta limitación abre la puerta a estrategias complementarias, como la explotación específica de segmentos o la incorporación explícita de información de clustering en futuras versiones del sistema.
Riesgos operativos y de uso indebido
Finalmente, existe el riesgo de interpretar el modelo como una herramienta de segmentación rígida, cuando en realidad su salida debe entenderse como una probabilidad sujeta a incertidumbre. Un uso inadecuado de umbrales, o una interpretación excesivamente determinista de las predicciones, podría conducir a decisiones subóptimas o a la exclusión sistemática de usuarios potencialmente valiosos.
Por este motivo, el modelo debe integrarse dentro de un marco de decisión más amplio, combinado con criterio experto, monitorización continua y mecanismos de revisión periódica.
Implementación y escalabilidad del sistema
Este apartado describe las consideraciones necesarias para llevar el modelo de predicción de compradores a un entorno operativo real, analizando los requisitos técnicos, las estrategias de despliegue, la actualización del modelo y los mecanismos de mantenimiento. El objetivo no es proponer una arquitectura cerrada, sino identificar los elementos clave que condicionan la escalabilidad, robustez y sostenibilidad del sistema en producción.
Requerimientos técnicos y coste computacional
El modelo final se basa en un enfoque de stacking que combina LightGBM y CatBoost con un meta-modelo de regresión logística. Durante la fase de inferencia, este tipo de arquitectura presenta un coste computacional moderado, compatible con entornos CPU estándar, y permite generar predicciones de forma eficiente incluso sobre grandes volúmenes de usuarios.
No obstante, el proceso de entrenamiento y optimización ha demostrado ser significativamente más costoso. Las fases de selección automática de variables y optimización de hiperparámetros mediante Optuna han requerido tiempos de ejecución elevados, en algunos casos entre 10 y 15 horas por ciclo completo. Este coste se ve incrementado por el uso de validación cruzada y métricas orientadas a la clase minoritaria.
Cabe destacar que el tercer ciclo de optimización no solo no aportó mejoras sustanciales, sino que incluso produjo un ligero empeoramiento de algunas métricas. Este resultado pone de manifiesto que, a partir de cierto punto, los beneficios marginales de una búsqueda exhaustiva decrecen rápidamente. En un entorno productivo, sería recomendable limitar estos procesos o emplear configuraciones más acotadas que ofrezcan resultados muy similares con un coste computacional sensiblemente inferior.
Estrategia de despliegue
El modelo puede desplegarse siguiendo dos estrategias principales, en función de las necesidades del negocio:
- Procesamiento batch: adecuado para escenarios como campañas de marketing, priorización diaria de usuarios o análisis periódicos. En este caso, el modelo se ejecuta sobre un conjunto de usuarios y genera scores que se integran en sistemas downstream (CRM, herramientas de automatización, etc.).
- Predicción online o tiempo real: viable gracias al bajo coste de inferencia. Esta opción permitiría evaluar la probabilidad de compra durante la navegación del usuario o en eventos clave, siempre que exista una infraestructura adecuada para la extracción de features en tiempo real.
En ambos casos, el modelo puede integrarse fácilmente en pipelines existentes mediante APIs o jobs programados, dado que su entrada se basa en variables estructuradas y su salida es una probabilidad interpretable.
Frecuencia de reentrenamiento y actualización de datos
Dado que el comportamiento de los usuarios y las estrategias comerciales evolucionan con el tiempo, el modelo requiere un esquema de reentrenamiento periódico para mantener su validez. La frecuencia óptima dependerá del ritmo de cambio del negocio, aunque un ciclo mensual o trimestral resulta razonable como punto de partida.
No es necesario repetir en cada reentrenamiento todo el proceso de optimización exhaustiva. Los resultados obtenidos sugieren que una configuración estable de hiperparámetros y variables puede mantenerse durante varios ciclos, realizando ajustes puntuales solo cuando se detecten caídas significativas de rendimiento o cambios estructurales en los datos.
Mantenimiento y monitorización del modelo
Para garantizar un uso fiable del sistema, es fundamental implementar mecanismos de monitorización continua. Estos deben incluir tanto métricas de rendimiento (precisión, recall, tasa de conversión en producción) como indicadores de data drift y concept drift, que alerten de cambios en la distribución de las variables o en la relación entre comportamiento y compra.
La detección temprana de estos fenómenos permitiría activar procesos de reentrenamiento o revisión antes de que el rendimiento del modelo se degrade de forma significativa.
Escalabilidad y refactorización del código
El desarrollo del proyecto se ha realizado en formato de cuaderno Jupyter, lo que ha facilitado la exploración, el análisis iterativo y la documentación del proceso. Sin embargo, esta estructura conduce a un código altamente secuencial y poco modular, que no es óptimo para un entorno productivo.
De cara a una implementación real, sería recomendable refactorizar el código en módulos reutilizables, separando claramente las fases de carga de datos, generación de features, entrenamiento, evaluación e inferencia. Esta modularización permitiría mejorar la mantenibilidad, reducir la duplicación de código y facilitar la automatización de los flujos de entrenamiento y despliegue.
En conjunto, el sistema desarrollado es técnicamente viable y escalable, pero su explotación eficiente requiere un equilibrio consciente entre complejidad, coste computacional y beneficio incremental, así como una transición controlada desde un entorno exploratorio hacia una arquitectura productiva más robusta.
Extensiones y próximos pasos
El trabajo desarrollado sienta una base sólida tanto a nivel predictivo como analítico. No obstante, existen múltiples líneas de extensión que permitirían ampliar el alcance del sistema, mejorar su rendimiento operativo y reforzar su aplicabilidad en entornos reales de producción. A continuación se describen los principales próximos pasos identificados a partir de los resultados obtenidos.
Incorporación de información de segmentación al modelo predictivo
Aunque la segmentación de usuarios se ha realizado de forma independiente al modelo supervisado, los resultados muestran que ciertos clusters concentran tasas de conversión muy superiores —o nulas— respecto a la media. Una extensión natural del trabajo consiste en evaluar si esta información aporta valor predictivo adicional.
En particular, podrían explorarse las siguientes aproximaciones:
- Incorporar el identificador de cluster como variable categórica adicional en el modelo supervisado.
- Construir variables derivadas de la segmentación (por ejemplo, pertenencia a clusters de alta o nula conversión).
- Evaluar modelos específicos por segmento, entrenados sobre subconjuntos homogéneos de usuarios.
Este enfoque permitiría comprobar si el modelo puede beneficiarse explícitamente de la estructura latente del espacio de usuarios, especialmente en la detección de perfiles minoritarios de alto valor que actualmente emergen solo en análisis a posteriori.
Profundización en el análisis de segmentación
El análisis de clustering realizado ha demostrado que la intención de compra se distribuye de forma continua y parcialmente solapada. Sin embargo, quedan abiertas varias líneas de profundización:
- Explorar segmentaciones específicas sobre subconjuntos relevantes, como únicamente compradores o usuarios con alta actividad.
- Analizar la estabilidad temporal de los clusters y su evolución a lo largo del tiempo.
- Evaluar la reproducibilidad de los clusters en muestras distintas o ventanas temporales independientes.
Estas extensiones permitirían distinguir entre segmentos estructurales y patrones circunstanciales, reforzando el valor estratégico de la segmentación.
Optimización del proceso de entrenamiento y selección de modelos
El proceso de optimización llevado a cabo, especialmente mediante Optuna y selección automática de variables, ha demostrado ser computacionalmente costoso, con ejecuciones de varias horas en algunas iteraciones. Los resultados indican que ciclos adicionales de optimización no aportaron mejoras significativas e incluso degradaron el rendimiento.
Como próximos pasos, se plantea:
- Reducir el espacio de búsqueda de hiperparámetros basándose en configuraciones ya validadas.
- Aplicar estrategias de optimización temprana (early stopping) más restrictivas.
- Reutilizar configuraciones cercanas al óptimo para nuevos entrenamientos, minimizando el coste computacional.
Estas mejoras permitirían alcanzar rendimientos comparables con un coste operativo sustancialmente menor.
Revisión de la estrategia de selección automática de variables
El proceso de selección automática de variables implementado mediante Sequential Floating Forward Selection (SFFS) permitió explorar combinaciones de características de forma sistemática, evaluando su contribución incremental al rendimiento del modelo. No obstante, esta técnica resultó computacionalmente muy costosa, con tiempos de ejecución elevados, y no produjo mejoras consistentes frente a conjuntos de variables definidos a partir del análisis exploratorio y de interpretabilidad.
Como línea de trabajo futura, se plantea revisar o incluso eliminar el uso de SFFS en favor de estrategias más eficientes y alineadas con la naturaleza de los modelos empleados. En particular, los resultados sugieren que el valor marginal de la selección exhaustiva de variables es limitado cuando se utilizan algoritmos como LightGBM y CatBoost, que incorporan mecanismos internos de selección y regularización.
En su lugar, podrían explorarse alternativas que permitan capturar posibles sinergias entre variables de forma más eficiente, tales como:
- Selección basada en importancias agregadas y estabilidad, priorizando variables estratégicas identificadas en el análisis multicriterio.
- Ingeniería de variables orientada a interacciones explícitas entre señales clave.
- Reducción del espacio de búsqueda mediante filtros previos basados en correlación y redundancia.
Este enfoque permitiría mantener un rendimiento predictivo comparable, reducir significativamente el coste computacional del pipeline y centrar los esfuerzos en transformaciones de mayor valor añadido, especialmente en entornos donde la escalabilidad y el tiempo de entrenamiento son factores críticos.
Refactorización y modularización del código
Debido a la naturaleza exploratoria del proyecto y a su entrega en formato de cuaderno Jupyter, el código presenta una estructura secuencial y altamente acoplada. Una extensión clave del trabajo consiste en refactorizar el pipeline hacia una arquitectura más modular y mantenible.
Entre las mejoras identificadas se incluyen:
- Separar claramente las etapas de preparación de datos, entrenamiento, evaluación e interpretación.
- Encapsular procesos repetitivos en funciones o clases reutilizables.
- Facilitar la generalización del pipeline a nuevos datasets o contextos de negocio.
Esta refactorización no solo mejoraría la calidad del código, sino que facilitaría su integración en sistemas de producción y su mantenimiento a largo plazo.
Extensión hacia entornos productivos
Finalmente, el sistema podría evolucionar hacia una solución plenamente operativa mediante su integración en pipelines de datos y procesos de negocio existentes. Esto incluye la automatización del scoring periódico, la monitorización del rendimiento y la detección de data drift o cambios en los patrones de comportamiento.
Estas extensiones permitirían cerrar el ciclo completo desde la modelización hasta la explotación continua del modelo, maximizando su impacto real en el negocio.
Conclusiones finales
El objetivo principal de este trabajo ha sido el desarrollo de un sistema predictivo capaz de identificar usuarios con alta probabilidad de compra en un entorno de comercio electrónico, prestando especial atención tanto al rendimiento del modelo como a su interpretabilidad y aplicabilidad práctica. A lo largo del proyecto se ha construido un pipeline completo que abarca desde la exploración y preparación de datos hasta la evaluación, optimización e interpretación avanzada del modelo.
Desde el punto de vista predictivo, los resultados obtenidos confirman que la combinación de modelos basados en gradient boosting mediante un esquema de stacking —integrando LightGBM y CatBoost con una regresión logística como meta-modelo— permite mejorar de forma consistente las métricas clave frente a enfoques individuales. El modelo final alcanza un mayor poder discriminatorio sobre la clase minoritaria, incrementa el lift en los deciles superiores y mejora la tasa de conversión esperada, manteniendo un control adecuado sobre las falsas alarmas. Estos resultados evidencian un alineamiento efectivo entre el rendimiento técnico y los objetivos de negocio.
El análisis de umbrales de probabilidad demuestra, además, que el modelo es operativamente versátil. Sin necesidad de reentrenamiento, puede adaptarse a distintos escenarios mediante la simple modificación del umbral de decisión, permitiendo priorizar captación, equilibrio o precisión según el contexto. Esta flexibilidad incrementa de forma significativa el valor práctico del sistema y facilita su integración en entornos reales con necesidades cambiantes.
Más allá del rendimiento, uno de los principales aportes del trabajo reside en el bloque de interpretabilidad e insights. El análisis multicriterio de importancia de variables, combinado con métricas de estabilidad, permite distinguir entre señales estratégicas —robustas y generalizables— y señales tácticas, cuyo valor depende del contexto o del segmento de usuario. Este enfoque supera la visión tradicional basada exclusivamente en rankings de importancia y aporta una comprensión más profunda de cómo y cuándo el modelo utiliza cada variable.
El estudio de explicaciones locales mediante SHAP pone de manifiesto que el modelo no aplica un razonamiento homogéneo a todos los usuarios, sino que combina las mismas señales con roles distintos según el patrón individual. A partir de este análisis emergen perfiles explicativos claros —usuarios de alta intención, exploradores mixtos y usuarios de baja intención— que permiten comprender la lógica interna del modelo a nivel individual.
La segmentación no supervisada complementa esta visión desde una perspectiva agregada. Aunque la intención de compra no se organiza en clusters perfectamente separados, la incorporación explícita de la dimensión de conversión permite identificar grupos con tasas de compra significativamente superiores a la media, así como segmentos estructuralmente poco propensos a la conversión. Resulta especialmente relevante la detección, mediante DBSCAN, de subgrupos minoritarios con comportamientos atípicos pero tasas de conversión excepcionalmente altas, lo que demuestra que lo aparentemente “atípico” no debe interpretarse necesariamente como ruido, sino como una fuente potencial de alto valor.
En conjunto, los resultados muestran que el modelo aprende patrones globales sólidos asociados a la compra, pero que una parte sustancial de su valor emerge al analizar cómo se distribuye la conversión entre distintos perfiles de comportamiento identificados a posteriori. La segmentación no supervisada no replica el razonamiento interno del modelo, pero actúa como una herramienta complementaria para contextualizar sus predicciones, identificar oportunidades de alto impacto y reconocer segmentos de bajo retorno.
Desde una perspectiva aplicada, el sistema desarrollado no solo permite priorizar usuarios con mayor probabilidad de compra, sino que proporciona un marco analítico que facilita decisiones informadas sobre a quién impactar, con qué intensidad y en qué contexto. Al mismo tiempo, el análisis explícito de limitaciones, costes computacionales y posibles extensiones deja abierta una vía clara para futuras mejoras, tanto en términos de eficiencia técnica como de generación de valor de negocio.
En definitiva, este trabajo demuestra que la combinación de modelos predictivos robustos con técnicas avanzadas de interpretabilidad y análisis exploratorio permite construir sistemas no solo precisos, sino también comprensibles, explotables y alineados con objetivos reales de negocio, sentando una base sólida para su aplicación y evolución en entornos productivos.
Bibliografia
Barba Alonso, M. (2022) Modelos predictivos de comportamiento de compra en comercio electrónico. Revista Española de Marketing, 56(3), pp. 112–128.
García, D. and Rodríguez, L. (2024) ‘Modelos híbridos de propensión de compra en mercados emergentes’, Journal of Business Analytics, 7(2), pp. 55–71.
Jiang, Y., Liu, Z. and Zhang, Q. (2024) ‘Sequential modeling for e-commerce user behavior prediction with deep learning architectures’, Expert Systems with Applications, 242, 122694.
Kim, S., Park, D. and Choi, J. (2024) ‘Handling class imbalance in online marketing datasets: A comparative study of adaptive resampling strategies’, Information Sciences, 658, pp. 119973.
Liu, F., Yang, X. and Zhou, P. (2024) ‘Balanced ensemble methods for customer propensity prediction’, Applied Intelligence, 54(6), pp. 4557–4574.
Martínez, L., Brown, T. and Davis, C. (2024) ‘Explainable AI for marketing decision-making: ethics and transparency in machine learning models’, Journal of Data Ethics, 3(1), pp. 22–38.
Pandiyarajan, R., Subramaniam, R. and Kumar, V. (2025) ‘Customer engagement analytics using behavioral features’, International Journal of E-Commerce Research, 19(1), pp. 45–63.
Singh, A., Verma, R. and Kumar, D. (2024) ‘Improved ensemble methods for imbalanced marketing datasets’, International Journal of Computer Applications, 219(4), pp. 37–49.
Wang, L., Chen, H. and Li, T. (2024) ‘User behavior prediction in e-commerce using Transformer-based architectures’, Knowledge-Based Systems, 295, 111635.
Yasnig, P. (2025) ‘Behavioral models for purchase intent prediction in e-commerce’, Data Mining Journal, 33(2), pp. 88–103.
Al-Ebrahim, M.A., Bunian, S. and Nour, A.A. (2024) ‘Recent machine-learning-driven developments in e-commerce: current challenges and future perspectives’, Engineered Science, 28, pp. 1–19.
de Vargas, V.W. (2022) ‘Imbalanced data preprocessing techniques for machine learning’, Journal of Big Data, 9(1), pp. 1–23.
Gkikas, D.C. (2024) ‘Predicting online shopping behavior: using machine learning and Google Analytics to classify user engagement’, Applied Sciences, 14(23), 11403.
Hesvindrati, N. (2025) Behavior-based purchase intent prediction in e-commerce. International Journal of Computer Science and Research Review, 6(4), pp. 45–58.
Zamora Pérez, A.L. (2025) ‘Predicción de la intención de compra en el comercio electrónico: caso de éxito’, Revista Internacional de Investigación y Desarrollo Global, 4(3), pp. 1–14.
Akiba, T., Sano, S., Yanase, T., Ohta, T. & Koyama, M. (2019). Optuna: A Next-generation Hyperparameter Optimization Framework. Proceedings of the 25th ACM SIGKDD Conference, pp. 2623–2631.
Lundberg, S.M. and Lee, S.-I. (2017) ‘A unified approach to interpreting model predictions’, Advances in Neural Information Processing Systems, 30, pp. 4765–4774.
max_depthyreg_lambdaen LightGBM, ymin_data_in_leafen CatBoost. Su eliminación responde a la alta correlación detectada con otros hiperparámetros estructurales y a la confianza en los mecanismos de control por defecto de los frameworks, reduciendo así la complejidad innecesaria del espacio de búsqueda.feature_fractionyboosting_typeen LightGBM, junto conrandom_strengthygrow_policyen CatBoost, introducen fuentes adicionales de regularización y aleatoriedad. En el meta-modelo, la incorporación deelasticnet,solver='saga'yl1_ratioamplía la flexibilidad para controlar el equilibrio entre parsimonia y capacidad predictiva.num_leaves— para explotar las nuevas features, mientras se refuerza la regularización en todas las capas del ensemble.